mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:05:19 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749d80aa85 | ||
|
|
29976f4e19 | ||
|
|
bb67ababaf | ||
|
|
e79bcf531b | ||
|
|
3e5043f875 | ||
|
|
11cd5527ed | ||
|
|
cf1bfdac6b | ||
|
|
b63a595b04 |
@@ -7,6 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
|
||||
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
|
||||
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
|
||||
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
|
||||
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
|
||||
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
|
||||
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
|
||||
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
|
||||
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
||||
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
||||
047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
|
||||
@@ -32,6 +40,10 @@
|
||||
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 */; };
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||
@@ -193,6 +205,10 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
04657AB42E633C6A002D5BBE /* NostrPoW.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoW.swift; sourceTree = "<group>"; };
|
||||
04657AB72E633C76002D5BBE /* PowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowPolicy.swift; sourceTree = "<group>"; };
|
||||
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiningAnimation.swift; sourceTree = "<group>"; };
|
||||
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoWTests.swift; sourceTree = "<group>"; };
|
||||
0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = "<group>"; };
|
||||
047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = "<group>"; };
|
||||
047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = "<group>"; };
|
||||
@@ -205,6 +221,8 @@
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
||||
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.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>"; };
|
||||
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||
@@ -445,6 +463,7 @@
|
||||
A55126E93155456CAA8D6656 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */,
|
||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||
@@ -484,6 +503,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */,
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
|
||||
@@ -519,6 +540,8 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04657AB72E633C76002D5BBE /* PowPolicy.swift */,
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
@@ -542,6 +565,7 @@
|
||||
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04657AB42E633C6A002D5BBE /* NostrPoW.swift */,
|
||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
||||
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||
@@ -771,6 +795,7 @@
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
||||
@@ -783,15 +808,18 @@
|
||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
||||
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
|
||||
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
|
||||
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
|
||||
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
|
||||
047502892E5416250083520F /* Geohash.swift in Sources */,
|
||||
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
|
||||
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */,
|
||||
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
|
||||
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
||||
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
||||
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
||||
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
||||
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */,
|
||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
||||
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
||||
);
|
||||
@@ -830,6 +858,7 @@
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||
@@ -842,15 +871,18 @@
|
||||
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
|
||||
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
|
||||
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
|
||||
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
|
||||
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
|
||||
047502872E5416250083520F /* Geohash.swift in Sources */,
|
||||
047502882E5416250083520F /* LocationChannel.swift in Sources */,
|
||||
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */,
|
||||
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
||||
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
||||
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */,
|
||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
||||
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
||||
);
|
||||
@@ -865,7 +897,9 @@
|
||||
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
|
||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -887,7 +921,9 @@
|
||||
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
|
||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
import Darwin
|
||||
|
||||
/// NIP-13 Proof-of-Work utilities for Nostr events.
|
||||
/// - Counts leading zero bits and mines a nonce tag to meet a target.
|
||||
enum NostrPoW {
|
||||
/// Table of leading zero bit counts for all 256 byte values.
|
||||
private static let lzTable: [UInt8] = {
|
||||
(0...255).map { v -> UInt8 in
|
||||
var x = UInt8(v)
|
||||
if x == 0 { return 8 }
|
||||
var n: UInt8 = 0
|
||||
while (x & 0x80) == 0 {
|
||||
n &+= 1
|
||||
x <<= 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
}()
|
||||
|
||||
/// Count leading zero bits in a 32-byte hash.
|
||||
static func leadingZeroBits(_ data: Data) -> Int {
|
||||
var total = 0
|
||||
for b in data {
|
||||
let lz = Int(lzTable[Int(b)])
|
||||
total += lz
|
||||
if lz != 8 { break }
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Compute Nostr event id hash (SHA-256 over canonical serialization) for given fields.
|
||||
private static func eventIDHash(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> (hex: String, data: Data) {
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let digest = SHA256.hash(data: json)
|
||||
let data = Data(digest)
|
||||
let hex = data.map { String(format: "%02x", $0) }.joined()
|
||||
return (hex, data)
|
||||
}
|
||||
|
||||
/// Mine a NIP-13 nonce tag that satisfies the target leading-zero bits.
|
||||
/// - Parameters:
|
||||
/// - pubkey: x-only hex pubkey (64-hex chars)
|
||||
/// - createdAt: seconds since epoch
|
||||
/// - kind: event kind
|
||||
/// - baseTags: existing tags (e.g., [ ["g", geohash], ["n", nickname], ... ])
|
||||
/// - content: event content
|
||||
/// - targetBits: required leading zero bits
|
||||
/// - startNonce: optional starting nonce (random if nil)
|
||||
/// - Returns: (nonce, idHex)
|
||||
static func mine(pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int,
|
||||
startNonce: UInt64? = nil) -> (nonce: UInt64, idHex: String) {
|
||||
var nonce: UInt64 = startNonce ?? randomNonce()
|
||||
// Build a local tags buffer once to avoid reallocating unrelated tags
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", "0", String(targetBits)])
|
||||
|
||||
var iter: UInt64 = 0
|
||||
while true {
|
||||
var solved = false
|
||||
var idHexOut = ""
|
||||
// Use an autorelease pool periodically to keep memory stable on iOS
|
||||
autoreleasepool {
|
||||
// Update nonce tag value (second element)
|
||||
tags[tags.count - 1][1] = String(nonce)
|
||||
let (idHex, idData) = eventIDHash(pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content)
|
||||
if leadingZeroBits(idData) >= targetBits {
|
||||
solved = true
|
||||
idHexOut = idHex
|
||||
}
|
||||
}
|
||||
if solved {
|
||||
return (nonce, idHexOut)
|
||||
}
|
||||
nonce &+= 1
|
||||
iter &+= 1
|
||||
if iter & 0x3FFF == 0 { /* yield every ~16k iterations */ _ = sched_yield() }
|
||||
}
|
||||
}
|
||||
|
||||
private static func randomNonce() -> UInt64 {
|
||||
var n: UInt64 = 0
|
||||
withUnsafeMutableBytes(of: &n) { ptr in
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, ptr.count, ptr.baseAddress!)
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
@@ -409,6 +409,8 @@ class BitchatMessage: Codable {
|
||||
let senderPeerID: String?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
// Mining state for Nostr geohash PoW (UI hint: hide timestamp, animate)
|
||||
var isMiningPow: Bool? // default nil/false
|
||||
|
||||
// Cached formatted text (not included in Codable)
|
||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||
@@ -421,13 +423,18 @@ class BitchatMessage: Codable {
|
||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||
}
|
||||
|
||||
func clearCachedFormattedText() {
|
||||
_cachedFormattedText.removeAll()
|
||||
}
|
||||
|
||||
// Codable implementation
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, sender, content, timestamp, isRelay, originalSender
|
||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||
case isMiningPow
|
||||
}
|
||||
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil, isMiningPow: Bool? = nil) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
@@ -439,6 +446,7 @@ class BitchatMessage: Codable {
|
||||
self.senderPeerID = senderPeerID
|
||||
self.mentions = mentions
|
||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||
self.isMiningPow = isMiningPow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +463,8 @@ extension BitchatMessage: Equatable {
|
||||
lhs.recipientNickname == rhs.recipientNickname &&
|
||||
lhs.senderPeerID == rhs.senderPeerID &&
|
||||
lhs.mentions == rhs.mentions &&
|
||||
lhs.deliveryStatus == rhs.deliveryStatus
|
||||
lhs.deliveryStatus == rhs.deliveryStatus &&
|
||||
(lhs.isMiningPow ?? false) == (rhs.isMiningPow ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,4 +87,28 @@ enum Geohash {
|
||||
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||
return (lat, lon)
|
||||
}
|
||||
|
||||
/// Decodes a geohash into its latitude and longitude bounds.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: (latMin, latMax, lonMin, lonMax)
|
||||
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
|
||||
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||
|
||||
var isEven = true
|
||||
for ch in geohash.lowercased() {
|
||||
guard let cd = base32Map[ch] else { continue }
|
||||
for mask in [16, 8, 4, 2, 1] {
|
||||
if isEven {
|
||||
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
||||
} else {
|
||||
let mid = (latInterval.0 + latInterval.1) / 2
|
||||
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
||||
}
|
||||
isEven.toggle()
|
||||
}
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
final class GeohashBookmarksStore: ObservableObject {
|
||||
static let shared = GeohashBookmarksStore()
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
membership.insert(gh)
|
||||
persist()
|
||||
// Resolve and persist a friendly name once when added
|
||||
resolveNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func remove(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard membership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||
membership.remove(gh)
|
||||
// Clean up stored name to avoid stale cache growth
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
bookmarks = list
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func normalize(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Name Resolution
|
||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||
func resolveNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolving.remove(gh) }
|
||||
if let pm = placemarks?.first {
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||
if let name = name, !name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
// Compose up to 2 names joined by ' and '
|
||||
let finalName: String? = {
|
||||
let names = uniqueAdmins.array
|
||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||
return names.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
self.resolving.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
uniqueAdmins.insert(admin)
|
||||
} else if let country = pm.country, !country.isEmpty {
|
||||
uniqueAdmins.insert(country)
|
||||
}
|
||||
}
|
||||
// Proceed to next point
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
// Minimal ordered-set for stable joining
|
||||
private struct OrderedSet<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
private(set) var array: [Element] = []
|
||||
mutating func insert(_ element: Element) {
|
||||
if set.insert(element).inserted { array.append(element) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
// Prefer administrative area if available at this coarse level
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
func _resetForTesting() {
|
||||
bookmarks.removeAll()
|
||||
membership.removeAll()
|
||||
bookmarkNames.removeAll()
|
||||
persist()
|
||||
persistNames()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
// Initialize teleported flag from persisted state if a location channel is selected
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
@@ -129,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on persisted state OR current location vs selected channel
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||
self.teleported = persisted || (currentGH != ch.geohash)
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// Simple default policy for NIP-13 difficulty on geohash chats.
|
||||
/// Progressive by geohash precision; adjustable later by live activity.
|
||||
enum PowPolicy {
|
||||
/// Determine target bits for a given geohash string.
|
||||
/// Clamped to a sane range to keep UX responsive on phones.
|
||||
static func requiredBits(forGeohash geohash: String) -> Int {
|
||||
let precision = geohash.count
|
||||
// Start at 13 and go down with higher precision (smaller areas)
|
||||
switch precision {
|
||||
case ...5: return 16
|
||||
case 6: return 15
|
||||
default: return 14
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,6 +382,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
|
||||
// Presentation state for privacy gating
|
||||
@Published var isLocationChannelsSheetPresented: Bool = false
|
||||
@Published var isAppInfoPresented: Bool = false
|
||||
@Published var showScreenshotPrivacyWarning: Bool = false
|
||||
|
||||
// Messages are naturally ephemeral - no persistent storage
|
||||
// Persist mesh public timeline across channel switches
|
||||
@@ -586,6 +591,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if connected {
|
||||
Task { @MainActor in
|
||||
self.resubscribeCurrentGeohash()
|
||||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,21 +621,41 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||
}
|
||||
|
||||
// Background: keep sampling nearby geohashes for notifications even when sheet is closed
|
||||
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
|
||||
LocationChannelManager.shared.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
guard let self = self else { return }
|
||||
let ghs = channels.map { $0.geohash }
|
||||
let regional = channels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: ghs)
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// Kick off initial sampling if we already have channels
|
||||
if !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
Task { @MainActor in self.beginGeohashSampling(for: ghs) }
|
||||
|
||||
// Also observe bookmark changes to update sampling
|
||||
GeohashBookmarksStore.shared.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bookmarks in
|
||||
guard let self = self else { return }
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Kick off initial sampling if we have regional channels or bookmarks
|
||||
do {
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
if !union.isEmpty {
|
||||
Task { @MainActor in self.beginGeohashSampling(for: union) }
|
||||
}
|
||||
}
|
||||
// Refresh channels once when authorized to seed sampling
|
||||
LocationChannelManager.shared.$permissionState
|
||||
@@ -634,6 +664,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if state == .authorized { LocationChannelManager.shared.refreshChannels() }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Track teleport flag changes to keep our own teleported marker in sync with regional status
|
||||
LocationChannelManager.shared.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isTeleported in
|
||||
guard let self = self else { return }
|
||||
Task { @MainActor in
|
||||
guard case .location(let ch) = self.activeChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if isTeleported && hasRegional && !inRegional {
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
} else {
|
||||
self.teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Request notification permission
|
||||
NotificationService.shared.requestAuthorization()
|
||||
@@ -746,6 +796,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
if self.processedNostrEvents.contains(event.id) { return }
|
||||
self.processedNostrEvents.insert(event.id)
|
||||
// PoW filter: require local difficulty for this geohash
|
||||
let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first
|
||||
let ghValue = ghFromTag ?? ch.geohash
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
let required = PowPolicy.requiredBits(forGeohash: ghValue)
|
||||
if powBits < required {
|
||||
// Drop low PoW event
|
||||
return
|
||||
}
|
||||
}
|
||||
if let gh = self.currentGeohash,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
@@ -763,8 +824,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
self.nostrKeyMapping[key8] = event.pubkey
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
// Update participants last-seen for this pubkey with observed PoW bits
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
|
||||
}
|
||||
|
||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||
@@ -772,10 +836,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
})
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
||||
let isSelf: Bool = {
|
||||
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
||||
}
|
||||
}
|
||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
@@ -1217,7 +1290,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
/// Routes to private chat if one is selected, otherwise broadcasts
|
||||
@MainActor
|
||||
func sendMessage(_ content: String) {
|
||||
guard !content.isEmpty else { return }
|
||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
// Check for commands
|
||||
if content.hasPrefix("/") {
|
||||
@@ -1237,7 +1312,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
// Parse mentions from the content
|
||||
// Parse mentions from the content (use original content for user intent)
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
// Add message to local display
|
||||
@@ -1252,14 +1327,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: displaySender,
|
||||
content: content,
|
||||
content: trimmed,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
mentions: mentions.isEmpty ? nil : mentions,
|
||||
deliveryStatus: nil,
|
||||
isMiningPow: {
|
||||
if case .location(_) = activeChannel { return true } else { return nil }
|
||||
}()
|
||||
)
|
||||
|
||||
// Add to main messages immediately for user feedback
|
||||
@@ -1292,17 +1371,42 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
if case .location(let ch) = activeChannel {
|
||||
// Send to geohash channel via Nostr ephemeral
|
||||
Task { @MainActor in
|
||||
// Send to geohash channel via Nostr ephemeral with PoW
|
||||
let msgID = message.id
|
||||
let contentToSend = trimmed
|
||||
Task {@MainActor in
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: self.nickname,
|
||||
teleported: LocationChannelManager.shared.teleported
|
||||
let createdAt = Int(message.timestamp.timeIntervalSince1970)
|
||||
var baseTags = [["g", ch.geohash]]
|
||||
if !self.nickname.isEmpty { baseTags.append(["n", self.nickname]) }
|
||||
if LocationChannelManager.shared.teleported { baseTags.append(["t", "teleport"]) }
|
||||
let bits = PowPolicy.requiredBits(forGeohash: ch.geohash)
|
||||
// Mine off the main thread
|
||||
let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let res = NostrPoW.mine(pubkey: identity.publicKeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
baseTags: baseTags,
|
||||
content: contentToSend,
|
||||
targetBits: bits)
|
||||
cont.resume(returning: (res.nonce, res.idHex))
|
||||
}
|
||||
}
|
||||
// Build final event with nonce tag and sign
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", String(nonce), String(bits)])
|
||||
var event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: message.timestamp,
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: contentToSend
|
||||
)
|
||||
let schnorrKey = try identity.schnorrSigningKey()
|
||||
event = try event.sign(with: schnorrKey)
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
@@ -1312,12 +1416,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
// Track ourselves as active participant
|
||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
if LocationChannelManager.shared.teleported {
|
||||
// Track ourselves as active participant with PoW record
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, geohash: ch.geohash, powBits: powBits)
|
||||
} else {
|
||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
}
|
||||
SecureLogger.log("Geo: sent geo event id=\(event.id.prefix(12))… bits=\(bits) nonce=\(String(nonce))", category: SecureLogger.session, level: .debug)
|
||||
// Reveal timestamp / stop animation
|
||||
if let idx = self.messages.firstIndex(where: { $0.id == msgID }) {
|
||||
self.messages[idx].isMiningPow = false
|
||||
self.messages[idx].clearCachedFormattedText()
|
||||
// Trigger UI refresh so timestamp appears
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
// If teleported and outside regional set, mark self teleported
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||
@@ -1326,6 +1443,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error)
|
||||
self.addSystemMessage("failed to send to location channel")
|
||||
if let idx = self.messages.firstIndex(where: { $0.id == msgID }) {
|
||||
self.messages[idx].isMiningPow = false
|
||||
self.messages[idx].clearCachedFormattedText()
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1348,16 +1470,38 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
messages = meshTimeline
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
stopGeoParticipantsTimer()
|
||||
geohashPeople = []
|
||||
teleportedGeo.removeAll()
|
||||
case .location(let ch):
|
||||
// Sanitize existing timeline (filter any prior empty-content entries)
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
let before = arr.count
|
||||
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
if arr.count != before { geoTimelines[ch.geohash] = arr }
|
||||
// Deduplicate by ID while preserving order (from oldest to newest)
|
||||
if arr.count > 1 {
|
||||
var seen = Set<String>()
|
||||
var dedup: [BitchatMessage] = []
|
||||
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||
if !seen.contains(m.id) {
|
||||
dedup.append(m)
|
||||
seen.insert(m.id)
|
||||
}
|
||||
}
|
||||
arr = dedup
|
||||
}
|
||||
// Persist the cleaned/sorted timeline for this geohash
|
||||
geoTimelines[ch.geohash] = arr
|
||||
messages = arr
|
||||
// Debug: log if any empty messages are present post-sanitize
|
||||
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
if emptyGeo > 0 {
|
||||
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
// Unsubscribe previous
|
||||
if let sub = geoSubscriptionID {
|
||||
@@ -1375,14 +1519,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
guard case .location(let ch) = channel else { return }
|
||||
currentGeohash = ch.geohash
|
||||
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
||||
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||
if LocationChannelManager.shared.teleported {
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
} else {
|
||||
teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
@@ -1401,6 +1549,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Deduplicate
|
||||
if self.processedNostrEvents.contains(event.id) { return }
|
||||
self.recordProcessedEvent(event.id)
|
||||
// PoW filter for geohash events
|
||||
let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first
|
||||
let ghValue = ghFromTag ?? ch.geohash
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
let required = PowPolicy.requiredBits(forGeohash: ghValue)
|
||||
if powBits < required {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Log incoming tags for diagnostics
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
|
||||
@@ -1411,10 +1569,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
})
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
||||
let isSelf: Bool = {
|
||||
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor in
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
@@ -1438,8 +1605,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.nostrKeyMapping[key16] = event.pubkey
|
||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
self.nostrKeyMapping[key8] = event.pubkey
|
||||
// Update participants last-seen for this pubkey
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
// Update participants last-seen for this pubkey with observed PoW bits
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
|
||||
}
|
||||
|
||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
@@ -1624,6 +1794,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Track last observed PoW bits per participant per geohash
|
||||
private var geoParticipantPowBits: [String: [String: Int]] = [:] // geohash -> (pubkeyLower -> bits)
|
||||
|
||||
private func recordGeoParticipant(pubkeyHex: String, geohash: String, powBits: Int?) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = geoParticipants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
geoParticipants[geohash] = map
|
||||
if let bits = powBits {
|
||||
var powMap = geoParticipantPowBits[geohash] ?? [:]
|
||||
powMap[key] = bits
|
||||
geoParticipantPowBits[geohash] = powMap
|
||||
}
|
||||
if currentGeohash == geohash {
|
||||
refreshGeohashPeople()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshGeohashPeople() {
|
||||
guard let gh = currentGeohash else { geohashPeople = []; return }
|
||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
@@ -1632,6 +1820,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
map = map.filter { $0.value >= cutoff }
|
||||
// Remove blocked Nostr pubkeys
|
||||
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||
// Enforce PoW threshold for peer list as well
|
||||
let required = PowPolicy.requiredBits(forGeohash: gh)
|
||||
let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased()
|
||||
let powMap = geoParticipantPowBits[gh] ?? [:]
|
||||
map = map.filter { (pub, _) in
|
||||
if let bits = powMap[pub] { return bits >= required }
|
||||
// Always allow self even if no PoW record yet
|
||||
if let me = myHex, pub == me { return true }
|
||||
// Otherwise, if no PoW info, hide until we see an acceptable event
|
||||
return false
|
||||
}
|
||||
geoParticipants[gh] = map
|
||||
// Build display list
|
||||
let people = map
|
||||
@@ -1662,9 +1861,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func visibleGeohashPeople() -> [GeoPerson] {
|
||||
guard let gh = currentGeohash else { return [] }
|
||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
let required = PowPolicy.requiredBits(forGeohash: gh)
|
||||
let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased()
|
||||
let powMap = geoParticipantPowBits[gh] ?? [:]
|
||||
let map = (geoParticipants[gh] ?? [:])
|
||||
.filter { $0.value >= cutoff }
|
||||
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||
.filter { (pub, _) in
|
||||
if let bits = powMap[pub] { return bits >= required }
|
||||
if let me = myHex, pub == me { return true }
|
||||
return false
|
||||
}
|
||||
let people = map
|
||||
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
@@ -1766,17 +1973,38 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
// Enforce PoW for sampled geohashes too
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
let required = PowPolicy.requiredBits(forGeohash: gh)
|
||||
if powBits < required { return }
|
||||
}
|
||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
let existingCount: Int = {
|
||||
let map = self.geoParticipants[gh] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}()
|
||||
// Update participants for this specific geohash
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
// Notify on new message activity in this geohash (sampling across channels)
|
||||
if let idData = Data(hexString: event.id) {
|
||||
let powBits = NostrPoW.leadingZeroBits(idData)
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh, powBits: powBits)
|
||||
} else {
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
}
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
// Respect geohash blocks
|
||||
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
// Skip self identity for this geohash
|
||||
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
||||
// Only trigger when there were zero participants in this geohash recently
|
||||
guard existingCount == 0 else { return }
|
||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||
// Foreground policy: allow if it's a different geohash than the one currently open
|
||||
// Suppress only when app is active AND we're already in this same geohash channel
|
||||
#if os(iOS)
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
@@ -1799,6 +2027,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}()
|
||||
Task { @MainActor in
|
||||
self.lastGeoNotificationAt[gh] = now
|
||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||
var arr = self.geoTimelines[gh] ?? []
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = self.geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if !arr.contains(where: { $0.id == msg.id }) {
|
||||
arr.append(msg)
|
||||
if arr.count > self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) }
|
||||
self.geoTimelines[gh] = arr
|
||||
}
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
@@ -2478,6 +2730,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
@objc private func userDidTakeScreenshot() {
|
||||
// Respect privacy: do not broadcast screenshots taken from non-chat sheets
|
||||
if isLocationChannelsSheetPresented {
|
||||
// Show a warning about sharing location screenshots publicly
|
||||
showScreenshotPrivacyWarning = true
|
||||
return
|
||||
}
|
||||
if isAppInfoPresented {
|
||||
// Silently ignore screenshots of app info
|
||||
return
|
||||
}
|
||||
|
||||
// Send screenshot notification based on current context
|
||||
let screenshotMessage = "* \(nickname) took a screenshot *"
|
||||
|
||||
@@ -2497,7 +2760,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Show local notification immediately as system message
|
||||
// Show local notification immediately as system message (only in chat)
|
||||
let localNotification = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "you took a screenshot",
|
||||
@@ -2523,32 +2786,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||
case .location(let ch):
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: screenshotMessage,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: self.nickname,
|
||||
teleported: LocationChannelManager.shared.teleported
|
||||
)
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
// Track ourselves as active participant
|
||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to send geohash screenshot message: \(error)", category: SecureLogger.session, level: .error)
|
||||
self.addSystemMessage("failed to send to location channel")
|
||||
}
|
||||
await mineAndSendGeohashEvent(content: screenshotMessage, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Show local notification immediately as system message
|
||||
// Show local notification immediately as system message (only in chat)
|
||||
let localNotification = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "you took a screenshot",
|
||||
@@ -2933,8 +3176,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||
|
||||
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||
let nsContent = contentText as NSString
|
||||
let nsLen = nsContent.length
|
||||
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
|
||||
// Combine and sort all matches
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
@@ -2951,12 +3196,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for (matchRange, matchType) in allMatches {
|
||||
// Add text before the match
|
||||
if let range = Range(matchRange, in: contentText) {
|
||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||
if lastEndIndex < range.lowerBound {
|
||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the match with appropriate styling
|
||||
@@ -2974,7 +3221,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||
|
||||
lastEndIndex = range.upperBound
|
||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2989,6 +3236,59 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
// MARK: - Helpers for mining UI
|
||||
@MainActor
|
||||
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
||||
let isSelf: Bool = {
|
||||
if let spid = message.senderPeerID {
|
||||
if spid == meshService.myPeerID { return true }
|
||||
if case .location(let ch) = activeChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||
let bare: String = spid.hasPrefix("nostr:") ? String(spid.dropFirst(6)) : String(spid.dropFirst(6))
|
||||
return bare.lowercased().hasPrefix(id.publicKeyHex.lowercased().prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
}
|
||||
}
|
||||
return spid == meshService.myPeerID
|
||||
}
|
||||
if message.sender == nickname { return true }
|
||||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||||
return false
|
||||
}()
|
||||
return isSelf
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func baseColorForMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> Color {
|
||||
let isDark = colorScheme == .dark
|
||||
return isSelfMessage(message) ? .orange : peerColor(for: message, isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func formatSenderPrefix(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
let isSelf = isSelfMessage(message)
|
||||
let isDark = colorScheme == .dark
|
||||
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||||
let (baseName, suffix) = splitSuffix(from: message.sender)
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = baseColor
|
||||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||||
senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced)
|
||||
if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||
senderStyle.link = url
|
||||
}
|
||||
var prefix = AttributedString()
|
||||
prefix.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||
prefix.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||
if !suffix.isEmpty {
|
||||
var suffixStyle = senderStyle
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
prefix.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
prefix.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
return prefix
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
@@ -3051,9 +3351,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// For extremely long content, render as plain text to avoid heavy regex/layout work,
|
||||
// unless the content includes Cashu tokens we want to chip-render below
|
||||
// Compute NSString-backed length for regex/nsrange correctness with multi-byte characters
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
let containsCashuEarly: Bool = {
|
||||
let rx = Regexes.quickCashuPresence
|
||||
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
|
||||
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
||||
}()
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||
var plainStyle = AttributeContainer()
|
||||
@@ -3071,8 +3374,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let lnurlRegex = Regexes.lnurl
|
||||
let lightningSchemeRegex = Regexes.lightningScheme
|
||||
let detector = Regexes.linkDetector
|
||||
|
||||
let nsLen = content.count
|
||||
let hasMentionsHint = content.contains("@")
|
||||
let hasHashtagsHint = content.contains("#")
|
||||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||
@@ -3152,17 +3453,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for (range, type) in allMatches {
|
||||
// Add text before match
|
||||
if let nsRange = Range(range, in: content) {
|
||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var beforeStyle = AttributeContainer()
|
||||
beforeStyle.foregroundColor = baseColor
|
||||
beforeStyle.font = isSelf
|
||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||
: .system(size: 14, design: .monospaced)
|
||||
if isMentioned {
|
||||
beforeStyle.font = beforeStyle.font?.bold()
|
||||
if lastEnd < nsRange.lowerBound {
|
||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var beforeStyle = AttributeContainer()
|
||||
beforeStyle.foregroundColor = baseColor
|
||||
beforeStyle.font = isSelf
|
||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||
: .system(size: 14, design: .monospaced)
|
||||
if isMentioned {
|
||||
beforeStyle.font = beforeStyle.font?.bold()
|
||||
}
|
||||
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||
}
|
||||
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||
}
|
||||
|
||||
// Add styled match
|
||||
@@ -3270,7 +3573,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||
}
|
||||
}
|
||||
lastEnd = nsRange.upperBound
|
||||
// Advance lastEnd safely in case of overlaps
|
||||
if lastEnd < nsRange.upperBound {
|
||||
lastEnd = nsRange.upperBound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3289,12 +3595,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Add timestamp at the end (smaller, light grey)
|
||||
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
timestampStyle.font = .system(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
// Add timestamp at the end (hide while PoW mining)
|
||||
let hideTimestamp = (message.isMiningPow ?? false)
|
||||
if !hideTimestamp {
|
||||
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
timestampStyle.font = .system(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
}
|
||||
} else {
|
||||
// System message
|
||||
var contentStyle = AttributeContainer()
|
||||
@@ -3368,19 +3677,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Regular expression to find @mentions
|
||||
let pattern = "@([\\p{L}0-9_]+)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||
let nsContent = contentText as NSString
|
||||
let nsLen = nsContent.length
|
||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
|
||||
var lastEndIndex = contentText.startIndex
|
||||
|
||||
for match in matches {
|
||||
// Add text before the mention
|
||||
if let range = Range(match.range(at: 0), in: contentText) {
|
||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||
if lastEndIndex < range.lowerBound {
|
||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the mention with highlight
|
||||
@@ -3390,7 +3703,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
mentionStyle.foregroundColor = Color.orange
|
||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
||||
|
||||
lastEndIndex = range.upperBound
|
||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4288,11 +4601,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
Task { @MainActor in
|
||||
let publicMentions = parseMentions(from: content)
|
||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let publicMentions = parseMentions(from: normalized)
|
||||
let msg = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
content: normalized,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -4466,14 +4780,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||
}
|
||||
|
||||
// Check if we have new mesh peers we haven't seen recently
|
||||
// Rising-edge only: previously zero peers, now > 0 peers
|
||||
let currentPeerSet = Set(meshPeers)
|
||||
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
||||
// Send notification if:
|
||||
// 1. We have mesh peers (not just Nostr-only)
|
||||
// 2. There are new peers we haven't seen (rising-edge)
|
||||
// 3. We haven't already notified since the last sustained-empty period
|
||||
if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable {
|
||||
let hadNone = self.recentlySeenPeers.isEmpty
|
||||
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
|
||||
self.hasNotifiedNetworkAvailable = true
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers = currentPeerSet
|
||||
@@ -4482,16 +4792,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .info)
|
||||
}
|
||||
} else {
|
||||
// No peers - schedule a graceful reset to avoid refiring on brief drops
|
||||
if self.networkResetTimer == nil {
|
||||
self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.hasNotifiedNetworkAvailable = false
|
||||
self.recentlySeenPeers.removeAll()
|
||||
self.networkResetTimer = nil
|
||||
SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
// No peers — immediately reset to allow next rising-edge to notify
|
||||
self.hasNotifiedNetworkAvailable = false
|
||||
self.recentlySeenPeers.removeAll()
|
||||
if self.networkResetTimer != nil {
|
||||
self.networkResetTimer?.invalidate()
|
||||
self.networkResetTimer = nil
|
||||
}
|
||||
SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
// Register ephemeral sessions for all connected peers
|
||||
@@ -4593,7 +4901,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
||||
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
|
||||
var mentions: [String] = []
|
||||
let peerNicknames = meshService.getPeerNicknames()
|
||||
@@ -4728,30 +5038,56 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func sendPublicRaw(_ content: String) {
|
||||
if case .location(let ch) = activeChannel {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: self.nickname,
|
||||
teleported: LocationChannelManager.shared.teleported
|
||||
)
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||
if targetRelays.isEmpty {
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
await mineAndSendGeohashEvent(content: content, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date())
|
||||
}
|
||||
return
|
||||
}
|
||||
// Default: send over mesh
|
||||
meshService.sendMessage(content, mentions: [])
|
||||
}
|
||||
|
||||
// MARK: - Geohash PoW helper for system/raw messages
|
||||
@MainActor
|
||||
private func mineAndSendGeohashEvent(content: String, geohash: String, nickname: String, teleported: Bool, createdAt: Date) async {
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||
let createdAtSecs = Int(createdAt.timeIntervalSince1970)
|
||||
var baseTags = [["g", geohash]]
|
||||
if !nickname.isEmpty { baseTags.append(["n", nickname]) }
|
||||
if teleported { baseTags.append(["t", "teleport"]) }
|
||||
let bits = PowPolicy.requiredBits(forGeohash: geohash)
|
||||
let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let res = NostrPoW.mine(pubkey: identity.publicKeyHex,
|
||||
createdAt: createdAtSecs,
|
||||
kind: NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
baseTags: baseTags,
|
||||
content: content,
|
||||
targetBits: bits)
|
||||
cont.resume(returning: (res.nonce, res.idHex))
|
||||
}
|
||||
}
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", String(nonce), String(bits)])
|
||||
var event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let key = try identity.schnorrSigningKey()
|
||||
event = try event.sign(with: key)
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
if relays.isEmpty {
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("❌ GeoPoW: failed to send event: \(error)", category: SecureLogger.session, level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||
|
||||
@@ -5660,9 +5996,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if isGeo && finalMessage.sender != "system" {
|
||||
if let gh = currentGeohash {
|
||||
var arr = geoTimelines[gh] ?? []
|
||||
arr.append(finalMessage)
|
||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||
geoTimelines[gh] = arr
|
||||
// Dedup by message ID before appending to per-geohash timeline
|
||||
if !arr.contains(where: { $0.id == finalMessage.id }) {
|
||||
arr.append(finalMessage)
|
||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||
geoTimelines[gh] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5677,38 +6016,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
guard channelMatches else { return }
|
||||
|
||||
// Background nudge: notify on new activity after inactivity threshold in current channel
|
||||
#if os(iOS)
|
||||
if UIApplication.shared.applicationState != .active {
|
||||
let channelKey: String = {
|
||||
switch activeChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let now = Date()
|
||||
if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold {
|
||||
// Optional: simple cooldown to avoid duplicate bursts
|
||||
let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast
|
||||
if now.timeIntervalSince(lastNotified) >= 60 {
|
||||
let title = activeChannelDisplayName()
|
||||
let body = "new chats!"
|
||||
if case .location(let ch) = activeChannel {
|
||||
// Attach deep link to open this geohash directly
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: ch.geohash, titlePrefix: title + " ", bodyPreview: body)
|
||||
} else {
|
||||
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)")
|
||||
}
|
||||
lastPublicActivityNotifyAt[channelKey] = now
|
||||
}
|
||||
}
|
||||
lastPublicActivityAt[channelKey] = now
|
||||
}
|
||||
#endif
|
||||
// Removed background nudge notification for generic "new chats!"
|
||||
|
||||
// Append via batching buffer (skip empty content)
|
||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
enqueuePublic(finalMessage)
|
||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
enqueuePublic(finalMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@State private var messageText = ""
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@@ -164,6 +165,8 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
.onAppear { viewModel.isAppInfoPresented = true }
|
||||
.onDisappear { viewModel.isAppInfoPresented = false }
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { viewModel.showingFingerprintFor != nil },
|
||||
@@ -278,8 +281,10 @@ struct ContentView: View {
|
||||
}
|
||||
}()
|
||||
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
||||
|
||||
ForEach(items, id: \.uiID) { item in
|
||||
// Filter out empty/whitespace-only messages to avoid blank rows
|
||||
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
ForEach(filteredItems, id: \.uiID) { item in
|
||||
let message = item.message
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Check if current user is mentioned
|
||||
@@ -298,10 +303,21 @@ struct ContentView: View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
let formatted = viewModel.formatMessageAsText(message, colorScheme: colorScheme)
|
||||
let prefixAttr = viewModel.formatSenderPrefix(message, colorScheme: colorScheme)
|
||||
let baseColor = viewModel.baseColorForMessage(message, colorScheme: colorScheme)
|
||||
let isSelfMsg = viewModel.isSelfMessage(message)
|
||||
if (message.isMiningPow ?? false) {
|
||||
// Single Text combines prefix + animated body, so wrapping matches final layout
|
||||
CombinedMiningText(prefix: prefixAttr, original: message.content, color: baseColor, isSelf: isSelfMsg)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(formatted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
@@ -459,7 +475,13 @@ struct ContentView: View {
|
||||
}
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
// Do not mark teleported when opening a geohash that is in our regional set.
|
||||
// If availableChannels is empty (e.g., cold start), defer marking and let
|
||||
// LocationChannelManager compute teleported based on actual location.
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||
}
|
||||
.onTapGesture(count: 3) {
|
||||
@@ -1112,6 +1134,15 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open unread private chat")
|
||||
}
|
||||
// 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")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||
}
|
||||
// Location channels button '#'
|
||||
Button(action: { showLocationChannelsSheet = true }) {
|
||||
let badgeText: String = {
|
||||
@@ -1166,6 +1197,13 @@ struct ContentView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
||||
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
||||
}
|
||||
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
|
||||
Button("ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
|
||||
}
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
@@ -1360,6 +1398,53 @@ struct ContentView: View {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Mining UI helpers (local to ensure inclusion in target)
|
||||
fileprivate struct CombinedMiningText: View {
|
||||
let prefix: AttributedString
|
||||
let original: String
|
||||
let color: Color
|
||||
let isSelf: Bool
|
||||
let fontSize: CGFloat = 14
|
||||
let interval: TimeInterval = 0.06
|
||||
@State private var display: String = ""
|
||||
@State private var timer: Timer? = nil
|
||||
|
||||
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
|
||||
|
||||
var body: some View {
|
||||
Text(combinedAttributed)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.onAppear {
|
||||
display = scramble(original)
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
||||
display = scramble(original)
|
||||
}
|
||||
}
|
||||
.onDisappear { timer?.invalidate(); timer = nil }
|
||||
}
|
||||
|
||||
private var combinedAttributed: AttributedString {
|
||||
var res = AttributedString()
|
||||
res.append(prefix)
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = color
|
||||
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
|
||||
res.append(AttributedString(display).mergingAttributes(style))
|
||||
return res
|
||||
}
|
||||
|
||||
private func scramble(_ s: String) -> String {
|
||||
var out = String()
|
||||
out.reserveCapacity(s.count)
|
||||
for ch in s {
|
||||
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
|
||||
out.append(charset.randomElement() ?? ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
// Rounded payment chip button
|
||||
|
||||
@@ -8,6 +8,7 @@ import AppKit
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -98,7 +99,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
@@ -112,7 +113,21 @@ struct LocationChannelsSheet: View {
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
|
||||
channelRow(
|
||||
title: geohashTitleWithCount(for: channel),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
@@ -188,6 +203,48 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("bookmarked")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.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
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
@@ -220,14 +277,24 @@ struct LocationChannelsSheet: View {
|
||||
return false
|
||||
}
|
||||
|
||||
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
@ViewBuilder
|
||||
private func channelRow(
|
||||
title: String,
|
||||
subtitlePrefix: String,
|
||||
subtitleName: String? = nil,
|
||||
subtitleNameBold: Bool = false,
|
||||
isSelected: Bool,
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
@@ -237,20 +304,17 @@ struct LocationChannelsSheet: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Text(subtitlePrefix)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
if let name = subtitleName {
|
||||
Text(" • ")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Text(name)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
}
|
||||
return subtitlePrefix
|
||||
}()
|
||||
Text(subtitleFull)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
@@ -258,11 +322,11 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -291,12 +355,19 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
||||
// Bookmarked list: show the #geohash as the main label
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "#\(geohash) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Mining body animation: scramble characters (matrix-like) until PoW completes.
|
||||
struct MiningScrambleText: View {
|
||||
let original: String
|
||||
let color: Color
|
||||
let isSelf: Bool
|
||||
let fontSize: CGFloat = 14
|
||||
let interval: TimeInterval = 0.06
|
||||
@State private var display: String = ""
|
||||
@State private var timer: Timer? = nil
|
||||
|
||||
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
|
||||
|
||||
var body: some View {
|
||||
Text(display)
|
||||
.font(.system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced))
|
||||
.foregroundColor(color)
|
||||
.onAppear {
|
||||
display = scramble(original)
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
||||
display = scramble(original)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
timer?.invalidate(); timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined prefix+body single-Text variant to ensure identical wrapping
|
||||
struct MiningCombinedText: View {
|
||||
let prefix: AttributedString
|
||||
let original: String
|
||||
let color: Color
|
||||
let isSelf: Bool
|
||||
let fontSize: CGFloat = 14
|
||||
let interval: TimeInterval = 0.06
|
||||
@State private var display: String = ""
|
||||
@State private var timer: Timer? = nil
|
||||
|
||||
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
|
||||
|
||||
var body: some View {
|
||||
Text(combinedAttributed)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.onAppear {
|
||||
display = scramble(original)
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
||||
display = scramble(original)
|
||||
}
|
||||
}
|
||||
.onDisappear { timer?.invalidate(); timer = nil }
|
||||
}
|
||||
|
||||
private var combinedAttributed: AttributedString {
|
||||
var res = AttributedString()
|
||||
res.append(prefix)
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = color
|
||||
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
|
||||
res.append(AttributedString(display).mergingAttributes(style))
|
||||
return res
|
||||
}
|
||||
|
||||
private func scramble(_ s: String) -> String {
|
||||
var out = String()
|
||||
out.reserveCapacity(s.count)
|
||||
for ch in s {
|
||||
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
|
||||
out.append(charset.randomElement() ?? ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
private func scramble(_ s: String) -> String {
|
||||
var out = String()
|
||||
out.reserveCapacity(s.count)
|
||||
for ch in s {
|
||||
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
|
||||
out.append(charset.randomElement() ?? ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
let storeKey = "locationChannel.bookmarks"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clear persisted state before each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean after each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testToggleAndNormalize() {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
// Start clean
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
|
||||
// Add with mixed case and hash prefix
|
||||
store.toggle("#U4PRUY")
|
||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
||||
|
||||
// Toggling again removes
|
||||
store.toggle("u4pruy")
|
||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
}
|
||||
|
||||
func testPersistenceWritten() throws {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
store.toggle("ezs42")
|
||||
store.toggle("u4pruy")
|
||||
|
||||
// Verify persisted JSON contains both (order not enforced here)
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
||||
XCTFail("No persisted data found")
|
||||
return
|
||||
}
|
||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||
XCTAssertTrue(arr.contains("ezs42"))
|
||||
XCTAssertTrue(arr.contains("u4pruy"))
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -2,17 +2,17 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
final class NostrPoWTests: XCTestCase {
|
||||
func testLeadingZeroBits() {
|
||||
// 0x00 -> 8, 0x00 -> 8, 0xF0 -> 0 leading zeros
|
||||
let data = Data([0x00, 0x00, 0xF0]) + Data(repeating: 0x00, count: 29)
|
||||
let lz = NostrPoW.leadingZeroBits(data)
|
||||
XCTAssertEqual(lz, 16)
|
||||
}
|
||||
|
||||
func testMineLowDifficulty() {
|
||||
let pubkey = String(repeating: "a", count: 64)
|
||||
let createdAt = Int(Date().timeIntervalSince1970)
|
||||
let kind = 20000
|
||||
let tags = [["g", "u4pruydqqvj"]]
|
||||
let content = "hello"
|
||||
let targetBits = 8 // keep it very low for test speed
|
||||
let (nonce, idHex) = NostrPoW.mine(pubkey: pubkey, createdAt: createdAt, kind: kind, baseTags: tags, content: content, targetBits: targetBits)
|
||||
XCTAssertGreaterThan(nonce, 0)
|
||||
// Verify difficulty
|
||||
let powTags = tags + [["nonce", String(nonce), String(targetBits)]]
|
||||
// Recompute ID
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, powTags, content]
|
||||
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let digest = CryptoKit.SHA256.hash(data: json)
|
||||
let idData = Data(digest)
|
||||
XCTAssertEqual(idHex.count, 64)
|
||||
XCTAssertGreaterThanOrEqual(NostrPoW.leadingZeroBits(idData), targetBits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user