Location notes: Matrix loader with 1–3s minimum display; allow multiple notes; wire notes UI from mesh toolbar

This commit is contained in:
jack
2025-09-13 00:40:41 +02:00
parent 920dc31795
commit d809351ab3
6 changed files with 436 additions and 13 deletions
+18 -13
View File
@@ -33,14 +33,16 @@
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 */; };
0481A3552E6D877600FC845E /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 0481A3532E6D877600FC845E /* README.md */; };
0481A3562E6D877600FC845E /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 0481A3532E6D877600FC845E /* README.md */; };
0481A3582E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; };
0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; };
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 */; };
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0481A3A92E74D28800FC845E /* LocationNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3A82E74D28800FC845E /* LocationNotesView.swift */; };
0481A3AA2E74D28800FC845E /* LocationNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3A82E74D28800FC845E /* LocationNotesView.swift */; };
0481A3AC2E74D29400FC845E /* LocationNotesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */; };
0481A3AD2E74D29400FC845E /* LocationNotesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
@@ -128,6 +130,10 @@
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
@@ -173,10 +179,6 @@
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -232,11 +234,12 @@
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; };
0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; };
0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = "<group>"; };
0481A3A82E74D28800FC845E /* LocationNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNotesView.swift; sourceTree = "<group>"; };
0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNotesManager.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
@@ -289,6 +292,8 @@
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
@@ -311,8 +316,6 @@
FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -372,7 +375,6 @@
children = (
0481A35A2E6D9BEF00FC845E /* libz.tbd */,
0481A35C2E6DA18600FC845E /* libz.tbd */,
0481A3532E6D877600FC845E /* README.md */,
0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */,
);
path = Frameworks;
@@ -515,6 +517,7 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
0481A3A82E74D28800FC845E /* LocationNotesView.swift */,
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
@@ -591,6 +594,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */,
0481A3452E6D869F00FC845E /* Tor */,
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
@@ -785,7 +789,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0481A3552E6D877600FC845E /* README.md in Resources */,
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
@@ -797,7 +800,6 @@
files = (
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
0481A3562E6D877600FC845E /* README.md in Resources */,
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -864,6 +866,8 @@
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
0481A3AD2E74D29400FC845E /* LocationNotesManager.swift in Sources */,
0481A3AA2E74D28800FC845E /* LocationNotesView.swift in Sources */,
047502892E5416250083520F /* Geohash.swift in Sources */,
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
@@ -929,6 +933,8 @@
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
0481A3AC2E74D29400FC845E /* LocationNotesManager.swift in Sources */,
0481A3A92E74D28800FC845E /* LocationNotesView.swift in Sources */,
047502872E5416250083520F /* Geohash.swift in Sources */,
047502882E5416250083520F /* LocationChannel.swift in Sources */,
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
@@ -984,7 +990,6 @@
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */,
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
+26
View File
@@ -123,6 +123,32 @@ struct NostrProtocol {
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
teleported: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
if teleported {
tags.append(["t", "teleport"])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods
+10
View File
@@ -755,6 +755,16 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For location notes: persistent text notes (kind 1) tagged with geohash
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
+133
View File
@@ -0,0 +1,133 @@
import Foundation
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
/// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor
final class LocationNotesManager: ObservableObject {
struct Note: Identifiable, Equatable {
let id: String
let pubkey: String
let content: String
let createdAt: Date
let nickname: String?
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
}
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var isLoading: Bool = true
@Published private(set) var geohash: String
private var subscriptionID: String?
private var loadingWorkItem: DispatchWorkItem?
private var loadingStartedAt: Date?
private var loadingMinDuration: TimeInterval = 0
init(geohash: String) {
self.geohash = geohash.lowercased()
subscribe()
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
if let sub = subscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
}
geohash = norm
notes.removeAll()
subscribe()
}
private func subscribe() {
// Begin loading: always display Matrix for a randomized 13 seconds
isLoading = true
loadingStartedAt = Date()
loadingMinDuration = Double.random(in: 1.0...3.0)
loadingWorkItem?.cancel()
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
subscriptionID = subID
let lookback = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashNotes(geohash, since: lookback, limit: 200)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
if self.notes.contains(where: { $0.id == event.id }) { return }
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
// Respect minimum loader time
if self.isLoading {
let elapsed = Date().timeIntervalSince(self.loadingStartedAt ?? Date())
if elapsed >= self.loadingMinDuration {
self.isLoading = false
} else {
self.loadingWorkItem?.cancel()
let remaining = self.loadingMinDuration - elapsed
let wi = DispatchWorkItem { [weak self] in self?.isLoading = false }
self.loadingWorkItem = wi
DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: wi)
}
}
}
// Ensure we hide after the minimum loader duration even if no events arrive
let wi = DispatchWorkItem { [weak self] in self?.isLoading = false }
loadingWorkItem = wi
DispatchQueue.main.asyncAfter(deadline: .now() + loadingMinDuration, execute: wi)
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
do {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname,
teleported: LocationChannelManager.shared.teleported
)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
NostrRelayManager.shared.sendEvent(event, to: relays)
// Optimistic local-echo
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
self.notes.insert(echo, at: 0)
if self.isLoading {
let elapsed = Date().timeIntervalSince(self.loadingStartedAt ?? Date())
if elapsed >= self.loadingMinDuration {
self.isLoading = false
} else {
self.loadingWorkItem?.cancel()
let remaining = self.loadingMinDuration - elapsed
let wi = DispatchWorkItem { [weak self] in self?.isLoading = false }
self.loadingWorkItem = wi
DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: wi)
}
}
} catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
}
}
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
subscriptionID = nil
}
loadingWorkItem?.cancel()
loadingWorkItem = nil
}
}
+33
View File
@@ -49,6 +49,7 @@ struct ContentView: View {
@State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = []
@State private var showLocationNotes = false
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:]
@@ -1144,6 +1145,16 @@ struct ContentView: View {
.buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
}
// Notes icon (mesh only), left of channel badge
if case .mesh = locationManager.selectedChannel {
Button(action: { showLocationNotes = true }) {
Image(systemName: "note.text")
.font(.system(size: 12))
.foregroundColor(Color(hue: 0.60, saturation: 0.85, brightness: 0.82))
}
.buttonStyle(.plain)
.accessibilityLabel("Location notes for this place")
}
// Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = {
@@ -1201,6 +1212,28 @@ struct ContentView: View {
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.sheet(isPresented: $showLocationNotes) {
// Determine current block-level geohash for this place
if let block = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .block })?.geohash {
LocationNotesView(geohash: block)
.environmentObject(viewModel)
} else {
VStack(spacing: 12) {
Text("location unavailable")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("enable location to view notes for your street-level area")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("enable location") {
LocationChannelManager.shared.enableLocationChannels()
}
.buttonStyle(.bordered)
}
.padding(20)
.background(backgroundColor)
.foregroundColor(textColor)
}
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
+216
View File
@@ -0,0 +1,216 @@
import SwiftUI
struct LocationNotesView: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject var manager: LocationNotesManager
let geohash: String
@Environment(\.colorScheme) var colorScheme
@State private var draft: String = ""
init(geohash: String) {
self.geohash = geohash.lowercased()
self.manager = LocationNotesManager(geohash: self.geohash)
}
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)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
var body: some View {
ZStack {
VStack(spacing: 0) {
header
Divider()
list
Divider()
input
}
if manager.isLoading {
MatrixRainView()
.transition(.opacity)
}
}
.background(backgroundColor)
.foregroundColor(textColor)
.onDisappear { manager.cancel() }
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("notes @ #\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("street-level notes")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
Spacer()
}
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
}
private var list: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
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(note.createdAt, style: .relative)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Text(note.content)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 12)
}
}
.padding(.vertical, 8)
}
.background(backgroundColor)
}
private var input: some View {
HStack(alignment: .center, spacing: 8) {
TextField("add a note for this place", text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.lineLimit(3, reservesSpace: true)
.padding(.horizontal, 12)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
}
.buttonStyle(.plain)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.padding(.trailing, 12)
}
.frame(minHeight: 44)
.padding(.vertical, 8)
.background(backgroundColor.opacity(0.95))
}
private func send() {
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname)
draft = ""
}
}
// MARK: - Matrix Rain Loader
private struct MatrixRainView: View {
@Environment(\.colorScheme) var colorScheme
private var fg: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private let charset = Array("01abcdefghijklmnopqrstuvwxyzアイウエオカキクケコハヒフヘホ0123456789")
var body: some View {
GeometryReader { geo in
let w = max(geo.size.width, 1)
let h = max(geo.size.height, 1)
let colWidth: CGFloat = 14
let cols = max(Int(w / colWidth), 6)
ZStack {
ForEach(0..<cols, id: \.self) { i in
RainColumn(charset: charset,
columnWidth: colWidth,
height: h,
speed: Double.random(in: 2.8...4.8),
delay: Double.random(in: 0...1.2),
color: fg)
.frame(width: colWidth)
.position(x: CGFloat(i) * colWidth + colWidth/2, y: h/2)
}
}
.background(Color.black.opacity(colorScheme == .dark ? 0.75 : 0.65))
.overlay(
VStack(spacing: 6) {
Text("loading notes…")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(fg)
.padding(.top, 12)
Spacer()
}
)
}
.ignoresSafeArea(edges: .bottom)
.allowsHitTesting(false)
}
}
private struct RainColumn: View {
let charset: [Character]
let columnWidth: CGFloat
let height: CGFloat
let speed: Double
let delay: Double
let color: Color
@State private var y: CGFloat = 0
@State private var glyphs: [String] = []
@State private var timer: Timer?
var body: some View {
let font = Font.system(size: 12, weight: .regular, design: .monospaced)
VStack(spacing: 0) {
ForEach(Array(glyphs.enumerated()), id: \.offset) { idx, ch in
Text(ch)
.font(font)
.foregroundColor(color.opacity(opacity(for: idx)))
.frame(width: columnWidth, alignment: .center)
}
}
.offset(y: y)
.onAppear {
let count = max(Int(height / 14) + 12, 24)
glyphs = randomGlyphs(count)
startGlyphTimer()
y = -height
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
withAnimation(.linear(duration: speed).repeatForever(autoreverses: false)) {
y = height * 2
}
}
}
.onDisappear { stopGlyphTimer() }
}
private func opacity(for idx: Int) -> Double {
let p = Double(idx) / Double(max(glyphs.count, 1))
return 0.15 + (1.0 - p) * 0.85
}
private func randomGlyphs(_ n: Int) -> [String] {
(0..<n).map { _ in String(charset.randomElement() ?? "0") }
}
private func startGlyphTimer() {
stopGlyphTimer()
timer = Timer.scheduledTimer(withTimeInterval: 0.20, repeats: true) { _ in
if glyphs.isEmpty { return }
let changes = Int.random(in: 1...3)
for _ in 0..<changes {
let idx = Int.random(in: 0..<glyphs.count)
glyphs[idx] = String(charset.randomElement() ?? "1")
}
}
}
private func stopGlyphTimer() {
timer?.invalidate()
timer = nil
}
}