mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b69b2aea | ||
|
|
a8855dd2ec |
@@ -33,6 +33,7 @@
|
||||
0481A3472E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; };
|
||||
0481A3482E6D869F00FC845E /* TorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3432E6D869F00FC845E /* TorManager.swift */; };
|
||||
0481A3492E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; };
|
||||
|
||||
0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; };
|
||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
|
||||
@@ -382,6 +383,7 @@
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
||||
0481A35A2E6D9BEF00FC845E /* libz.tbd */,
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
||||
|
||||
);
|
||||
path = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -813,10 +815,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
<<<<<<< Updated upstream
|
||||
|
||||
=======
|
||||
>>>>>>> Stashed changes
|
||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
final class LocationNotesCounter: ObservableObject {
|
||||
static let shared = LocationNotesCounter()
|
||||
@@ -16,11 +16,10 @@ final class LocationNotesCounter: ObservableObject {
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
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
|
||||
if geohash == norm { return }
|
||||
cancel()
|
||||
geohash = norm
|
||||
count = 0
|
||||
noteIDs.removeAll()
|
||||
initialLoadComplete = false
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ final class NotificationService {
|
||||
}
|
||||
|
||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||
let title = "🔒 DM from \(sender)"
|
||||
let title = "🔒 private message from \(sender)"
|
||||
let body = message
|
||||
let identifier = "private-\(UUID().uuidString)"
|
||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||
|
||||
@@ -40,11 +40,7 @@ static void *tor_thread_main(void *arg) {
|
||||
int rc = tor_run_main(cfg); // blocks until tor exits
|
||||
tor_main_configuration_free(cfg);
|
||||
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
|
||||
// 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;
|
||||
if (owning_fd_tor != -1) { close(owning_fd_tor); owning_fd_tor = -1; }
|
||||
return (void*)(intptr_t)rc;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,38 +86,6 @@ final class SecureLogger {
|
||||
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
|
||||
|
||||
enum SecurityEvent {
|
||||
@@ -191,9 +159,7 @@ final class SecureLogger {
|
||||
let errorDesc = sanitize(error.localizedDescription)
|
||||
|
||||
#if DEBUG
|
||||
let line = "\(location) Error: \(sanitized) — \(errorDesc)"
|
||||
os_log("%{public}@", log: category, type: .error, line)
|
||||
record(line)
|
||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
#else
|
||||
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
#endif
|
||||
@@ -237,7 +203,6 @@ private extension SecureLogger {
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||
record(sanitized)
|
||||
#else
|
||||
// In release builds, only log non-debug messages
|
||||
if level != .debug {
|
||||
@@ -255,7 +220,6 @@ private extension SecureLogger {
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@", log: .security, type: level.osLogType, message)
|
||||
record(message)
|
||||
#else
|
||||
// In release, use private logging to prevent sensitive data exposure
|
||||
os_log("%{private}@", log: .security, type: level.osLogType, message)
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct AppInfoView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -91,14 +86,10 @@ struct AppInfoView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
Button("close") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,77 +187,12 @@ struct AppInfoView: View {
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
#if DEBUG
|
||||
// Debug section (visible only in Debug builds)
|
||||
DebugLogsSection(textColor: textColor, secondaryTextColor: secondaryTextColor)
|
||||
.padding(.top)
|
||||
#endif
|
||||
.padding(.top)
|
||||
}
|
||||
.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 {
|
||||
let title: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -1140,31 +1140,7 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open unread private chat")
|
||||
}
|
||||
// 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
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
@@ -1173,7 +1149,6 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||
}
|
||||
|
||||
// Location channels button '#'
|
||||
Button(action: { showLocationChannelsSheet = true }) {
|
||||
let badgeText: String = {
|
||||
@@ -1199,8 +1174,29 @@ struct ContentView: View {
|
||||
.accessibilityLabel("location channels")
|
||||
}
|
||||
.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) {
|
||||
// People icon with count
|
||||
@@ -1212,7 +1208,6 @@ struct ContentView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(headerCountColor)
|
||||
.padding(.leading, 2)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
@@ -1282,10 +1277,11 @@ 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
|
||||
@@ -1528,16 +1524,8 @@ extension ContentView {
|
||||
if locationManager.permissionState == .authorized {
|
||||
LocationChannelManager.shared.refreshChannels()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||
LocationNotesCounter.shared.subscribe(geohash: building)
|
||||
} else {
|
||||
LocationNotesCounter.shared.cancel()
|
||||
}
|
||||
|
||||
@@ -14,14 +14,12 @@ 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. 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))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -56,30 +54,19 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
Button("close") { isPresented = false }
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
Button("close") { isPresented = false }
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -90,7 +77,6 @@ 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 {
|
||||
@@ -219,49 +205,44 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
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
|
||||
@@ -279,12 +260,9 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
|
||||
@@ -13,7 +13,7 @@ struct LocationNotesSheet: View {
|
||||
|
||||
var body: some View {
|
||||
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
|
||||
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 building = channels.first(where: { $0.level == .building }) {
|
||||
notesGeohash = building.geohash
|
||||
if notesGeohash == nil, let block = channels.first(where: { $0.level == .block }) {
|
||||
notesGeohash = block.geohash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ 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) {
|
||||
@@ -59,11 +55,8 @@ struct LocationNotesView: View {
|
||||
let c = manager.notes.count
|
||||
Text("\(c) \(c == 1 ? "note" : "notes") ")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
Text("@ ")
|
||||
Text("@ #\(geohash)")
|
||||
.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)
|
||||
@@ -97,18 +90,10 @@ struct LocationNotesView: View {
|
||||
ForEach(manager.notes) { note in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// 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)]")
|
||||
Text(note.displayName)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
Text(timestampText(for: note.createdAt))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
@@ -189,16 +174,3 @@ 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, "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user