Compare commits

...
Author SHA1 Message Date
jack a8887e7318 Resolve rebase/stash conflict in project.pbxproj: keep single tor-nolzma reference and clean markers 2025-09-13 23:15:36 +02:00
jack 63918bfebc Fix compile error: attach .padding(.top) inside #if DEBUG so modifier applies to view expression 2025-09-13 23:11:38 +02:00
jack b30a30ecce Debug: add in-memory log buffer and AppInfoView debug section with copy/clear logs (DEBUG only) 2025-09-13 23:11:38 +02:00
de2b5ed142 Fix/various UI fixes (#605)
* UI: replace textual 'close' with X icon\n\n- AppInfoView (iOS): use xmark icon in nav bar to match Location Notes style.\n- LocationChannelsSheet: use xmark icon for close on iOS/macOS toolbars; add accessibility label.

* Location Notes: prefix usernames with @ and lighten #geohash\n\n- Show @ before usernames in notes list.\n- Split header into '@' and '#geohash' and color the geohash with secondary green for consistency.

* Header spacing: add breathing room between channel badge, notes button, and people count\n\n- Add trailing padding after #mesh/#geohash badge.\n- Add leading padding before notes button and people counter to improve readability.

* Header: nudge #mesh/#geohash badge right with leading padding

* Location Notes + Header polish\n\n- Header: add space in '@ #geohash' and use darker green for geohash.\n- Notes list: render '@name#abcd' with darker green for #abcd to match chat.\n- Header: move geochat bookmark icon after #geohash badge with consistent spacing.

* Location Notes: @name regular green, #abcd darker; nudge #hash\n\n- Render '@' and base name in regular green, suffix '#abcd' in darker green.\n- Add extra left padding before '#geohash' in notes header.\n- Increase leading padding for channel badge to push #mesh/#geohash further right.

* Fix notes icon color: subscribe/count at block-level geohash\n\n- Use block (precision 7) geohash for notes: when opening sheet, on channel changes, and when subscribing the counter.\n- Aligns with LocationNotesManager which publishes at street-level geohash, allowing the counter to detect notes and turn icon blue.

* Header spacing: move #mesh/#geohash closer to notes/bookmark\n\n- Reduce trailing padding on channel badge and leading padding on notes/bookmark to cluster them together.\n- Keeps larger gap before people count for readability.

* Notes: standardize on building-level (8 chars) for publish/read\n\n- Use .building geohash when opening notes, reacting to channel updates, and subscribing the counter.\n- Update comments to reflect building-level scope.

* Location Channels sheet: use black sheet background like other sheets\n\n- Add backgroundColor and apply to container and list.\n- Hide list default background with scrollContentBackground(.hidden).

* Notes icon: use green when notes exist (matches app green)

* Location Channels: boxed 'bookmarked' section; keep 'remove location access' outside box\n\n- Wrap bookmarked list in a rounded, subtle grey box within the list.\n- Ensure the 'remove location access' button is not inside the box and clears list row background.

* Notes counter UX: avoid grey flicker when closing sheet\n\n- Preserve last count during resubscribe to prevent brief 0 state.\n- Keep existing subscription when building geohash temporarily unavailable; only cancel if none or permission revoked.

* Notes counter: unsubscribe without clearing count on resubscribe\n\n- Avoid calling cancel() in subscribe; just unsubscribe old sub to prevent wiping count to 0.\n- Prevents green→grey flicker after closing sheet or location updates.

* Notes icon: use sheet count until counter finishes initial load; don’t zero on sheet close\n\n- Compute hasNotes using max(count, sheetCount) while initialLoadComplete is false.\n- Remove sheetNotesCount reset on sheet disappear to avoid transient grey.

* Location Notes header: remove extra gap before #geohash (drop leading padding)

* Notes sheet: color #abcd suffix as darker green via opacity (match chat)

* Notes sheet: show timestamp in brackets; drop #abcd from @name

* chore: commit remaining local changes

* Header: move notes + bookmark to left of #mesh/#geohash

* Header spacing: tighten gap between #mesh/#geohash and peer count

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 23:05:19 +02:00
9 changed files with 260 additions and 86 deletions
+3 -2
View File
@@ -33,7 +33,6 @@
0481A3472E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; }; 0481A3472E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; };
0481A3482E6D869F00FC845E /* TorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3432E6D869F00FC845E /* TorManager.swift */; }; 0481A3482E6D869F00FC845E /* TorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3432E6D869F00FC845E /* TorManager.swift */; };
0481A3492E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; }; 0481A3492E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; };
0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; }; 0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; };
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; }; 0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
@@ -383,7 +382,6 @@
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */, 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
0481A35A2E6D9BEF00FC845E /* libz.tbd */, 0481A35A2E6D9BEF00FC845E /* libz.tbd */,
0481A35C2E6DA18600FC845E /* libz.tbd */, 0481A35C2E6DA18600FC845E /* libz.tbd */,
); );
path = Frameworks; path = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -815,7 +813,10 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */, 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
); );
+5 -4
View File
@@ -1,6 +1,6 @@
import Foundation 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 @MainActor
final class LocationNotesCounter: ObservableObject { final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter() static let shared = LocationNotesCounter()
@@ -16,10 +16,11 @@ 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 { return } if geohash == norm, subscriptionID != nil { return }
cancel() // Unsubscribe previous without clearing count to avoid flicker
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 = "🔒 private message from \(sender)" let title = "🔒 DM 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]
+37 -1
View File
@@ -86,6 +86,38 @@ final class SecureLogger {
return level.order >= minimumLevel.order return level.order >= minimumLevel.order
} }
// MARK: - In-memory debug buffer (DEBUG builds only)
#if DEBUG
private static let logBufferQueue = DispatchQueue(label: "chat.bitchat.securelogger.buffer")
private static var logBuffer: [String] = []
private static let logBufferCap: Int = 2000
/// Append a sanitized line to the in-memory debug buffer
private static func record(_ line: String) {
logBufferQueue.async {
logBuffer.append(line)
if logBuffer.count > logBufferCap {
logBuffer.removeFirst(logBuffer.count - logBufferCap)
}
}
}
/// Get the current logs as an array (oldest first)
static func getLogs() -> [String] {
return logBufferQueue.sync { logBuffer }
}
/// Get the current logs as a single string
static func getLogText() -> String {
return getLogs().joined(separator: "\n")
}
/// Clear the in-memory debug buffer
static func clearLogs() {
logBufferQueue.async { logBuffer.removeAll(keepingCapacity: false) }
}
#endif
// MARK: - Security Event Types // MARK: - Security Event Types
enum SecurityEvent { enum SecurityEvent {
@@ -159,7 +191,9 @@ final class SecureLogger {
let errorDesc = sanitize(error.localizedDescription) let errorDesc = sanitize(error.localizedDescription)
#if DEBUG #if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) let line = "\(location) Error: \(sanitized)\(errorDesc)"
os_log("%{public}@", log: category, type: .error, line)
record(line)
#else #else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif #endif
@@ -203,6 +237,7 @@ private extension SecureLogger {
#if DEBUG #if DEBUG
os_log("%{public}@", log: category, type: level.osLogType, sanitized) os_log("%{public}@", log: category, type: level.osLogType, sanitized)
record(sanitized)
#else #else
// In release builds, only log non-debug messages // In release builds, only log non-debug messages
if level != .debug { if level != .debug {
@@ -220,6 +255,7 @@ private extension SecureLogger {
#if DEBUG #if DEBUG
os_log("%{public}@", log: .security, type: level.osLogType, message) os_log("%{public}@", log: .security, type: level.osLogType, message)
record(message)
#else #else
// In release, use private logging to prevent sensitive data exposure // In release, use private logging to prevent sensitive data exposure
os_log("%{private}@", log: .security, type: level.osLogType, message) os_log("%{private}@", log: .security, type: level.osLogType, message)
+78 -4
View File
@@ -1,4 +1,9 @@
import SwiftUI import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct AppInfoView: View { struct AppInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@@ -86,10 +91,14 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("close") { Button(action: { dismiss() }) {
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")
} }
} }
} }
@@ -187,12 +196,77 @@ struct AppInfoView: View {
.background(Color.red.opacity(0.1)) .background(Color.red.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.padding(.top) #if DEBUG
// Debug section (visible only in Debug builds)
DebugLogsSection(textColor: textColor, secondaryTextColor: secondaryTextColor)
.padding(.top)
#endif
} }
.padding() .padding()
} }
} }
#if DEBUG
private struct DebugLogsSection: View {
let textColor: Color
let secondaryTextColor: Color
@State private var logsText: String = SecureLogger.getLogText()
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
SectionHeader("DEBUG")
HStack(spacing: 12) {
Button {
copyToPasteboard(logsText)
} label: {
Text("copy logs")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
.buttonStyle(.plain)
Button {
SecureLogger.clearLogs()
logsText = ""
} label: {
Text("clear logs")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
.buttonStyle(.plain)
}
ScrollView {
Text(logsText.isEmpty ? "(no logs)" : logsText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(8)
}
.frame(minHeight: 180)
.background(secondaryTextColor.opacity(0.08))
.cornerRadius(8)
}
.onReceive(timer) { _ in
logsText = SecureLogger.getLogText()
}
}
private func copyToPasteboard(_ text: String) {
#if os(iOS)
UIPasteboard.general.string = text
#elseif os(macOS)
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
#endif
}
}
#endif
struct SectionHeader: View { struct SectionHeader: View {
let title: String let title: String
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
+40 -28
View File
@@ -1140,7 +1140,31 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open unread private chat") .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 { 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")
@@ -1149,6 +1173,7 @@ 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 = {
@@ -1174,29 +1199,8 @@ struct ContentView: View {
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 4)
// Notes icon (mesh only and when location is authorized), to the right of #mesh .padding(.trailing, 2)
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
@@ -1208,6 +1212,7 @@ 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)
@@ -1277,11 +1282,10 @@ 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,
notesGeohash != current { notesGeohash != current {
notesGeohash = current notesGeohash = current
#if os(iOS) #if os(iOS)
// Light taptic when geohash changes while the sheet is open // Light taptic when geohash changes while the sheet is open
@@ -1524,8 +1528,16 @@ extension ContentView {
if locationManager.permissionState == .authorized { if locationManager.permissionState == .authorized {
LocationChannelManager.shared.refreshChannels() LocationChannelManager.shared.refreshChannels()
} }
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { if locationManager.permissionState == .authorized {
LocationNotesCounter.shared.subscribe(geohash: building) 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 { } else {
LocationNotesCounter.shared.cancel() LocationNotesCounter.shared.cancel()
} }
+60 -38
View File
@@ -14,12 +14,14 @@ 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.") 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)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -54,19 +56,30 @@ 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("close") { isPresented = false } Button(action: { isPresented = false }) {
.font(.system(size: 14, design: .monospaced)) Image(systemName: "xmark")
.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("close") { isPresented = false } Button(action: { isPresented = false }) {
.font(.system(size: 14, design: .monospaced)) Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
} }
#endif #endif
@@ -77,6 +90,7 @@ 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 {
@@ -205,44 +219,49 @@ struct LocationChannelsSheet: View {
// Bookmarked geohashes // Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty { if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 8) {
Text("bookmarked") Text("bookmarked")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .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) .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 // Footer action inside the list
@@ -260,9 +279,12 @@ 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 == .block })?.geohash { if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .building })?.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 block = channels.first(where: { $0.level == .block }) { if notesGeohash == nil, let building = channels.first(where: { $0.level == .building }) {
notesGeohash = block.geohash notesGeohash = building.geohash
} }
} }
} }
+33 -5
View File
@@ -27,6 +27,10 @@ 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) {
@@ -55,8 +59,11 @@ 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("@ #\(geohash)") Text("@ ")
.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)
@@ -90,10 +97,18 @@ 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) {
Text(note.displayName) // Show @name without the #abcd suffix; timestamp in brackets
.font(.system(size: 12, weight: .semibold, design: .monospaced)) HStack(spacing: 0) {
.foregroundColor(secondaryTextColor) Text("@")
Text(timestampText(for: note.createdAt)) .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)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(secondaryTextColor.opacity(0.8))
} }
@@ -174,3 +189,16 @@ 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, "")
}