Location Notes (kind 1) at Building Precision + Live Updates (#598)

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

* Location notes: prevent duplicate loading/subscriptions (StateObject manager, geohash captured at open, guard subscribe)

* Location notes: fix duplicate  state redeclaration in ContentView

* Location notes: render note bodies with monospaced font in notes list

* Location notes: add close (x) button in sheet header (upper-right) using @Environment(\.dismiss)

* Location notes: acquire geohash on open (force refresh) and show Matrix loader until block-level geohash resolves; add LocationNotesSheet wrapper

* Location notes: inline sheet wrapper to avoid missing file in target; force location refresh and show Matrix loader until block geohash resolves

* Location notes: remove Matrix animation; use simple spinner when acquiring location; simplify notes view layout

* Location notes: remove per-note relative timestamp from sheet (no seconds display)

* Location notes: fix intermittent first load by ensuring resubscribe after geohash change, removing over-eager subscribe guard, and widening note fetch window (no since filter)

* Location notes: add background counter service and show count next to notes icon on mesh; auto-subscribe to current block geohash

* Location notes: move notes icon to the right of #mesh badge in toolbar

* Location notes: restore timestamps in notes list; remove loading state from manager and sheet (no spinner/animation)

* Location notes: drop 'teleport' tag from kind-1 events; simplify note builder API and usage

* Location notes: show timestamp as relative within 7 days, else absolute date (MMM d or MMM d, y); keep monospaced styling

* Location notes: append 'ago' to relative timestamps (within 7 days)

* Switch location notes and UI helpers to building-level geohash (precision 8): add GeohashChannelLevel.building, map length 8, use building for notes selection and counter, add building name mapping

* Location notes: change notes toolbar icon to SF Symbol 'long.text.page.and.pencil'

* Revert notes geohash selection back to block-level (precision 7); keep building level available but unused; update counter and sheet accordingly

* Location notes: show block name (from reverse geocode) in header instead of 'street-level notes'

* Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only

* Fix Swift 6 isolation: hop to @MainActor inside Timer callback for EOSE tracker mutations

* Notes counter: show 0 by default; subscribe at building-level (precision 8) for notes and counter; keep building hidden in channel list

* Notes header: show building name when available (fallback to block name)

* Notes: subscribe counter to building + parent block (merge results); hide notes icon unless location is authorized; replace 10s timer with live location updates while sheet open

* Notes counter: subscribe only to building geohash (precision 8) so count reflects current 8-cell; auto-begin live location refresh on mesh (and permission) to update as you walk

* Notes header: show count in parentheses; icon shows blue if any notes, grey if none; only start live location updates while sheet is open; reduce nickname width; set live distance filter to 10m

* Notes header: show count as '(N note/notes)' with non-bold, secondary styling next to title

* Notes header: move count before title as '<N> note(s) @ #gh'; remove parentheses; keep count non-bold

* Notes header: bold count text; ensure sheet updates when building geohash changes by calling manager.setGeohash on geohash change

* Notes sheet: add light haptic feedback when building geohash changes (iOS only)

* Notes icon: force a one-shot location refresh before (re)subscribing the counter so icon turns blue immediately on current 8-cell

* Notes subscribe: fallback to default relays when GeoRelayDirectory has no entries (pass nil relayUrls) so counter/icon turn blue and sheet loads even before georelay fetch

* Notes icon: turn blue immediately when sheet shows notes by passing count up from sheet (closure) and OR-ing with counter; reset on sheet close

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-09-13 14:55:04 +02:00
committed by GitHub
co-authored by jack
parent 920dc31795
commit 2870acdcc7
13 changed files with 649 additions and 33 deletions
+30 -19
View File
@@ -33,14 +33,18 @@
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 */; };
0481A3AF2E74E06300FC845E /* LocationNotesCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */; };
0481A3B02E74E06300FC845E /* LocationNotesCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AE2E74E06300FC845E /* LocationNotesCounter.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 +132,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 +181,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 +236,13 @@
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>"; };
0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNotesCounter.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 +295,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 +319,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 +378,6 @@
children = (
0481A35A2E6D9BEF00FC845E /* libz.tbd */,
0481A35C2E6DA18600FC845E /* libz.tbd */,
0481A3532E6D877600FC845E /* README.md */,
0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */,
);
path = Frameworks;
@@ -515,6 +520,7 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
0481A3A82E74D28800FC845E /* LocationNotesView.swift */,
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
@@ -592,6 +598,8 @@
isa = PBXGroup;
children = (
0481A3452E6D869F00FC845E /* Tor */,
0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */,
0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */,
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
@@ -785,7 +793,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 +804,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;
@@ -834,6 +840,7 @@
047502B92E560F690083520F /* RelayController.swift in Sources */,
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */,
0481A3B02E74E06300FC845E /* LocationNotesCounter.swift in Sources */,
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */,
B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */,
@@ -864,6 +871,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 */,
@@ -899,6 +908,7 @@
047502BA2E560F690083520F /* RelayController.swift in Sources */,
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */,
0481A3AF2E74E06300FC845E /* LocationNotesCounter.swift in Sources */,
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */,
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */,
@@ -929,6 +939,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 +996,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 */,
@@ -1101,7 +1112,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -1132,7 +1143,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -1187,7 +1198,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -1219,7 +1230,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -1308,7 +1319,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -1401,7 +1412,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+22
View File
@@ -123,6 +123,28 @@ 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
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
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
+47 -4
View File
@@ -45,6 +45,14 @@ final class NostrRelayManager: ObservableObject {
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:]
private var cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
@@ -201,7 +209,8 @@ final class NostrRelayManager: ObservableObject {
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date()
@@ -250,6 +259,22 @@ final class NostrRelayManager: ObservableObject {
map[id] = messageString
self.pendingSubscriptions[url] = map
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls)
@@ -418,9 +443,17 @@ final class NostrRelayManager: ObservableObject {
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose:
// No-op for now
break
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
eoseTrackers[subId] = tracker
}
}
case .ok(let eventId, let success, let reason):
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
@@ -755,6 +788,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
+6
View File
@@ -2,6 +2,7 @@ import Foundation
/// Levels of location channels mapped to geohash precisions.
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case building
case block
case neighborhood
case city
@@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
/// Geohash length used for this level.
var precision: Int {
switch self {
case .building: return 8
case .block: return 7
case .neighborhood: return 6
case .city: return 5
@@ -21,6 +23,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String {
switch self {
case .building: return "Building"
case .block: return "Block"
case .neighborhood: return "Neighborhood"
case .city: return "City"
@@ -35,6 +38,7 @@ extension GeohashChannelLevel {
let container = try decoder.singleValueContainer()
if let raw = try? container.decode(String.self) {
switch raw {
case "building": self = .building
case "block": self = .block
case "neighborhood": self = .neighborhood
case "city": self = .city
@@ -46,6 +50,7 @@ extension GeohashChannelLevel {
}
} else if let precision = try? container.decode(Int.self) {
switch precision {
case 8: self = .building
case 7: self = .block
case 6: self = .neighborhood
case 5: self = .city
@@ -61,6 +66,7 @@ extension GeohashChannelLevel {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .building: try container.encode("building")
case .block: try container.encode("block")
case .neighborhood: try container.encode("neighborhood")
case .city: try container.encode("city")
@@ -285,12 +285,18 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
// Block: reuse neighborhood/locality granularity without exposing street level
// Block: reuse neighborhood/locality granularity
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
// Building: prefer place name/street/venue when available
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict
}
}
@@ -0,0 +1,52 @@
import Foundation
/// Lightweight background counter for location notes (kind 1) at block-level geohash.
@MainActor
final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter()
@Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private init() {}
func subscribe(geohash gh: String) {
let norm = gh.lowercased()
if geohash == norm { return }
cancel()
geohash = norm
count = 0
noteIDs.removeAll()
initialLoadComplete = false
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
let relayUrls: [String]? = relays.isEmpty ? nil : relays
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
if !self.noteIDs.contains(event.id) {
self.noteIDs.insert(event.id)
self.count = self.noteIDs.count
}
}, onEOSE: { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
}
}
@@ -0,0 +1,98 @@
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 geohash: String
@Published private(set) var initialLoadComplete: Bool = false
private var subscriptionID: String?
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)
subscriptionID = nil
}
geohash = norm
notes.removeAll()
subscribe()
}
private func subscribe() {
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
subscriptionID = subID
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
let relayUrls: [String]? = relays.isEmpty ? nil : relays
initialLoadComplete = false
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [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 }
}, onEOSE: { [weak self] in
self?.initialLoadComplete = true
})
}
/// 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
)
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)
} 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
}
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ enum TransportConfig {
// Location
static let locationDistanceFilterMeters: Double = 1000
// Live (channel sheet open) distance threshold for meaningful updates
static let locationDistanceFilterLiveMeters: Double = 21.0
static let locationDistanceFilterLiveMeters: Double = 10.0
static let locationLiveRefreshInterval: TimeInterval = 5.0
// Notifications (geohash)
+1 -1
View File
@@ -858,7 +858,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
Task { @MainActor in
self.torRestartPending = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage("tor restarting to recover connectivity")
self.addGeohashOnlySystemMessage("tor restarting to recover connectivity...")
}
}
+142 -6
View File
@@ -25,6 +25,7 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var notesCounter = LocationNotesCounter.shared
@State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@@ -49,6 +50,10 @@ struct ContentView: View {
@State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = []
@State private var showLocationNotes = false
@State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0
// Timer-based refresh removed; use LocationChannelManager live updates instead
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:]
@@ -466,14 +471,14 @@ struct ContentView: View {
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
}
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
// Do not mark teleported when opening a geohash that is in our regional set.
@@ -1091,7 +1096,7 @@ struct ContentView: View {
TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 100)
.frame(maxWidth: 80)
.foregroundColor(textColor)
.focused($isNicknameFieldFocused)
.autocorrectionDisabled(true)
@@ -1170,6 +1175,29 @@ struct ContentView: View {
}
.buttonStyle(.plain)
// Notes icon (mesh only and when location is authorized), to the right of #mesh
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
Button(action: {
// Kick a one-shot refresh and show the sheet immediately.
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
// If we already have a block geohash, pass it; otherwise wait in the sheet.
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
let hasNotes = ((notesCounter.count ?? 0) > 0) || (sheetNotesCount > 0)
Image(systemName: "long.text.page.and.pencil")
.font(.system(size: 12))
.foregroundColor(hasNotes ? Color(hue: 0.60, saturation: 0.85, brightness: 0.82) : Color.gray)
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
}
.buttonStyle(.plain)
.accessibilityLabel("Location notes for this place")
}
HStack(spacing: 4) {
// People icon with count
Image(systemName: "person.2.fill")
@@ -1180,9 +1208,12 @@ struct ContentView: View {
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
}
.layoutPriority(3)
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
@@ -1201,6 +1232,91 @@ struct ContentView: View {
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.sheet(isPresented: $showLocationNotes) {
Group {
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
.environmentObject(viewModel)
} else {
VStack(spacing: 12) {
HStack {
Text("notes")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Text("location unavailable")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("enable location") {
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
// per-sheet global onChange added below
}
}
.onAppear {
// Ensure we are authorized and start live location updates (distance-filtered)
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.beginLiveRefresh()
}
.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
#if os(iOS)
// Light taptic when geohash changes while the sheet is open
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
}
.onAppear {
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.onChange(of: locationManager.selectedChannel) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
.onChange(of: locationManager.permissionState) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
@@ -1399,6 +1515,26 @@ struct ContentView: View {
}
// MARK: - Notes Counter Subscription Helper
extension ContentView {
private func updateNotesCounterSubscription() {
switch locationManager.selectedChannel {
case .mesh:
// Ensure we have a fresh one-shot location fix so building geohash is current
if locationManager.permissionState == .authorized {
LocationChannelManager.shared.refreshChannels()
}
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesCounter.shared.subscribe(geohash: building)
} else {
LocationNotesCounter.shared.cancel()
}
case .location:
LocationNotesCounter.shared.cancel()
}
}
}
// MARK: - Helper Views
// Rounded payment chip button
+2 -1
View File
@@ -107,7 +107,7 @@ struct LocationChannelsSheet: View {
// Nearby options
if !manager.availableChannels.isEmpty {
ForEach(manager.availableChannels) { channel in
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
@@ -381,6 +381,7 @@ struct LocationChannelsSheet: View {
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8: return .building
default: return .block
}
}
+65
View File
@@ -0,0 +1,65 @@
import SwiftUI
struct LocationNotesSheet: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Binding var notesGeohash: String?
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var textColor: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
private var secondaryTextColor: Color { textColor.opacity(0.8) }
var body: some View {
Group {
if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .block })?.geohash {
// Found block geohash: show notes view
LocationNotesView(geohash: gh)
.environmentObject(viewModel)
} else {
// Acquire location: keep a loading overlay (Matrix) until we either get a block geohash
ZStack {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("notes")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("acquiring location…")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Spacer()
}
MatrixRainView()
.transition(.opacity)
}
.background(backgroundColor)
.foregroundColor(textColor)
.onAppear {
LocationChannelManager.shared.enableLocationChannels()
// Nudge a fresh fix
LocationChannelManager.shared.refreshChannels()
}
.onChange(of: locationManager.availableChannels) { channels in
if notesGeohash == nil, let block = channels.first(where: { $0.level == .block }) {
notesGeohash = block.geohash
}
}
}
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import SwiftUI
struct LocationNotesView: View {
@EnvironmentObject var viewModel: ChatViewModel
@StateObject private var manager: LocationNotesManager
let geohash: String
let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme
@ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
let gh = geohash.lowercased()
self.geohash = gh
self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
}
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 {
VStack(spacing: 0) {
header
Divider()
list
Divider()
input
}
.background(backgroundColor)
.foregroundColor(textColor)
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
let c = manager.notes.count
Text("\(c) \(c == 1 ? "note" : "notes") ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("@ #\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced))
}
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
Text(buildingName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
Text(blockName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
}
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.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(timestampText(for: note.createdAt))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Text(note.content)
.font(.system(size: 14, design: .monospaced))
.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: - Timestamp Formatting
private func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
// Relative (minute/hour/day), no seconds
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
} else {
// Absolute date (MMM d or MMM d, yyyy if different year)
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date)
}
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
private static let absDateYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d, y")
return f
}()
}