Compare commits

..
Author SHA1 Message Date
jack 2b363b7062 Resolve merge conflicts in ChatViewModel.swift (geohash sampling comments and removed background nudge) 2025-08-28 09:09:55 +01:00
jack 7d672f2d69 Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact 2025-08-28 08:58:12 +01:00
jack d35d3f9612 Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive) 2025-08-28 08:54:55 +01:00
jack 9e79b18dcb Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet. 2025-08-28 00:44:42 +01:00
10 changed files with 139 additions and 702 deletions
+1 -25
View File
@@ -7,14 +7,6 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
047502812E53A0FC0083520F /* 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 */; }; 047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
@@ -205,10 +197,6 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; 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>"; }; 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>"; }; 047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = "<group>"; };
@@ -463,7 +451,6 @@
A55126E93155456CAA8D6656 /* Views */ = { A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */,
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */, AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */,
@@ -502,9 +489,8 @@
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D69A18D27F9A565FD6041E12 /* Info.plist */,
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */,
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */, 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
D69A18D27F9A565FD6041E12 /* Info.plist */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */, 047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */, 980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
@@ -540,7 +526,6 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
04657AB72E633C76002D5BBE /* PowPolicy.swift */,
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */, 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
@@ -565,7 +550,6 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = { E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
04657AB42E633C6A002D5BBE /* NostrPoW.swift */,
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */, A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */, 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
@@ -808,18 +792,15 @@
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */, 049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */, E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */, 049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
047502892E5416250083520F /* Geohash.swift in Sources */, 047502892E5416250083520F /* Geohash.swift in Sources */,
0475028A2E5416250083520F /* LocationChannel.swift in Sources */, 0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */,
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */, 049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */, 049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */, 049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */, 049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */, AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */, E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
); );
@@ -871,18 +852,15 @@
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */, 049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */, 68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */, 049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
047502872E5416250083520F /* Geohash.swift in Sources */, 047502872E5416250083520F /* Geohash.swift in Sources */,
047502882E5416250083520F /* LocationChannel.swift in Sources */, 047502882E5416250083520F /* LocationChannel.swift in Sources */,
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */,
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */, 049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */, 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */, 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */, 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */, AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */, E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
); );
@@ -897,7 +875,6 @@
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */, 8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
@@ -921,7 +898,6 @@
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */, 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
-96
View File
@@ -1,96 +0,0 @@
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
}
}
+2 -11
View File
@@ -409,8 +409,6 @@ class BitchatMessage: Codable {
let senderPeerID: String? let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking 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) // Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:] private var _cachedFormattedText: [String: AttributedString] = [:]
@@ -423,18 +421,13 @@ class BitchatMessage: Codable {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text _cachedFormattedText["\(isDark)-\(isSelf)"] = text
} }
func clearCachedFormattedText() {
_cachedFormattedText.removeAll()
}
// Codable implementation // Codable implementation
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus 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, isMiningPow: Bool? = 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) {
self.id = id ?? UUID().uuidString self.id = id ?? UUID().uuidString
self.sender = sender self.sender = sender
self.content = content self.content = content
@@ -446,7 +439,6 @@ class BitchatMessage: Codable {
self.senderPeerID = senderPeerID self.senderPeerID = senderPeerID
self.mentions = mentions self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
self.isMiningPow = isMiningPow
} }
} }
@@ -463,8 +455,7 @@ extension BitchatMessage: Equatable {
lhs.recipientNickname == rhs.recipientNickname && lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID && lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions && lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus && lhs.deliveryStatus == rhs.deliveryStatus
(lhs.isMiningPow ?? false) == (rhs.isMiningPow ?? false)
} }
} }
-17
View File
@@ -1,17 +0,0 @@
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
}
}
}
+104 -338
View File
@@ -382,11 +382,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var showBluetoothAlert = false @Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @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 // Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches // Persist mesh public timeline across channel switches
@@ -796,17 +791,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return } if self.processedNostrEvents.contains(event.id) { return }
self.processedNostrEvents.insert(event.id) 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, if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
@@ -824,11 +808,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength)) let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey with observed PoW bits // Update participants last-seen for this pubkey
if let idData = Data(hexString: event.id) { self.recordGeoParticipant(pubkeyHex: event.pubkey)
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 // Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in let hasTeleportTag = event.tags.contains(where: { tag in
@@ -1334,11 +1315,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: localSenderPeerID, 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 // Add to main messages immediately for user feedback
@@ -1371,42 +1348,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
if case .location(let ch) = activeChannel { if case .location(let ch) = activeChannel {
// Send to geohash channel via Nostr ephemeral with PoW // Send to geohash channel via Nostr ephemeral
let msgID = message.id Task { @MainActor in
let contentToSend = trimmed
Task {@MainActor in
do { do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let createdAt = Int(message.timestamp.timeIntervalSince1970) let event = try NostrProtocol.createEphemeralGeohashEvent(
var baseTags = [["g", ch.geohash]] content: trimmed,
if !self.nickname.isEmpty { baseTags.append(["n", self.nickname]) } geohash: ch.geohash,
if LocationChannelManager.shared.teleported { baseTags.append(["t", "teleport"]) } senderIdentity: identity,
let bits = PowPolicy.requiredBits(forGeohash: ch.geohash) nickname: self.nickname,
// Mine off the main thread teleported: LocationChannelManager.shared.teleported
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( let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash, toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount count: TransportConfig.nostrGeoRelayCount
@@ -1416,22 +1368,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else { } else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
// Track ourselves as active participant with PoW record // Track ourselves as active participant
if let idData = Data(hexString: event.id) { self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
let powBits = NostrPoW.leadingZeroBits(idData) SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, geohash: ch.geohash, powBits: powBits) category: SecureLogger.session, level: .debug)
} else { // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) // Only when not in our regional set (and regional list is known)
}
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 hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if LocationChannelManager.shared.teleported && hasRegional && !inRegional { if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
@@ -1443,11 +1385,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} catch { } catch {
SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error)
self.addSystemMessage("failed to send to location channel") 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 { } else {
@@ -1470,11 +1407,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch channel { switch channel {
case .mesh: case .mesh:
messages = meshTimeline 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() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
teleportedGeo.removeAll() teleportedGeo.removeAll()
@@ -1482,26 +1414,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Sanitize existing timeline (filter any prior empty-content entries) // Sanitize existing timeline (filter any prior empty-content entries)
var arr = geoTimelines[ch.geohash] ?? [] var arr = geoTimelines[ch.geohash] ?? []
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
// Deduplicate by ID while preserving order (from oldest to newest) // Ensure chronological order when returning to a geohash
if arr.count > 1 { if arr.count > 1 {
var seen = Set<String>() arr.sort { $0.timestamp < $1.timestamp }
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 // Persist the cleaned/sorted timeline for this geohash
geoTimelines[ch.geohash] = arr geoTimelines[ch.geohash] = arr
messages = 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 // Unsubscribe previous
if let sub = geoSubscriptionID { if let sub = geoSubscriptionID {
@@ -1549,16 +1468,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Deduplicate // Deduplicate
if self.processedNostrEvents.contains(event.id) { return } if self.processedNostrEvents.contains(event.id) { return }
self.recordProcessedEvent(event.id) 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 // Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
@@ -1605,11 +1514,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.nostrKeyMapping[key16] = event.pubkey self.nostrKeyMapping[key16] = event.pubkey
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength)) let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey with observed PoW bits // Update participants last-seen for this pubkey
if let idData = Data(hexString: event.id) { self.recordGeoParticipant(pubkeyHex: event.pubkey)
let powBits = NostrPoW.leadingZeroBits(idData)
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
}
let senderName = self.displayNameForNostrPubkey(event.pubkey) let senderName = self.displayNameForNostrPubkey(event.pubkey)
let content = event.content let content = event.content
@@ -1794,24 +1700,6 @@ 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() { private func refreshGeohashPeople() {
guard let gh = currentGeohash else { geohashPeople = []; return } guard let gh = currentGeohash else { geohashPeople = []; return }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
@@ -1820,17 +1708,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
map = map.filter { $0.value >= cutoff } map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys // Remove blocked Nostr pubkeys
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } 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 geoParticipants[gh] = map
// Build display list // Build display list
let people = map let people = map
@@ -1861,17 +1738,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func visibleGeohashPeople() -> [GeoPerson] { func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] } guard let gh = currentGeohash else { return [] }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) 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] ?? [:]) let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff } .filter { $0.value >= cutoff }
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } .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 let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen } .sorted { $0.lastSeen > $1.lastSeen }
@@ -1973,12 +1842,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue 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 // Compute current participant count (5-minute window) BEFORE updating with this event
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let existingCount: Int = { let existingCount: Int = {
@@ -1986,12 +1849,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return map.values.filter { $0 >= cutoff }.count return map.values.filter { $0 >= cutoff }.count
}() }()
// Update participants for this specific geohash // Update participants for this specific geohash
if let idData = Data(hexString: event.id) { self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
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 // Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
@@ -2730,17 +2588,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
@objc private func userDidTakeScreenshot() { @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 // Send screenshot notification based on current context
let screenshotMessage = "* \(nickname) took a screenshot *" let screenshotMessage = "* \(nickname) took a screenshot *"
@@ -2760,7 +2607,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// Show local notification immediately as system message (only in chat) // Show local notification immediately as system message
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -2786,12 +2633,32 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
meshService.sendMessage(screenshotMessage, mentions: []) meshService.sendMessage(screenshotMessage, mentions: [])
case .location(let ch): case .location(let ch):
Task { @MainActor in Task { @MainActor in
await mineAndSendGeohashEvent(content: screenshotMessage, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date()) 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")
}
} }
} }
// Show local notification immediately as system message (only in chat) // Show local notification immediately as system message
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -3176,10 +3043,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let nsContent = contentText as NSString let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let nsLen = nsContent.length let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
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 // Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = [] var allMatches: [(range: NSRange, type: String)] = []
@@ -3196,14 +3061,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (matchRange, matchType) in allMatches { for (matchRange, matchType) in allMatches {
// Add text before the match // Add text before the match
if let range = Range(matchRange, in: contentText) { if let range = Range(matchRange, in: contentText) {
if lastEndIndex < range.lowerBound { let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) if !beforeText.isEmpty {
if !beforeText.isEmpty { var normalStyle = AttributeContainer()
var normalStyle = AttributeContainer() normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.font = .system(size: 14, design: .monospaced) normalStyle.foregroundColor = isDark ? Color.white : Color.black
normalStyle.foregroundColor = isDark ? Color.white : Color.black processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
} }
// Add the match with appropriate styling // Add the match with appropriate styling
@@ -3221,7 +3084,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle)) processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound } lastEndIndex = range.upperBound
} }
} }
@@ -3236,59 +3099,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return processedContent 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 @MainActor
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
@@ -3351,12 +3161,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For extremely long content, render as plain text to avoid heavy regex/layout work, // 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 // 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 containsCashuEarly: Bool = {
let rx = Regexes.quickCashuPresence let rx = Regexes.quickCashuPresence
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
}() }()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
@@ -3374,6 +3181,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let lnurlRegex = Regexes.lnurl let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector let detector = Regexes.linkDetector
let nsLen = content.count
let hasMentionsHint = content.contains("@") let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#") let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
@@ -3453,19 +3262,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (range, type) in allMatches { for (range, type) in allMatches {
// Add text before match // Add text before match
if let nsRange = Range(range, in: content) { if let nsRange = Range(range, in: content) {
if lastEnd < nsRange.lowerBound { let beforeText = String(content[lastEnd..<nsRange.lowerBound])
let beforeText = String(content[lastEnd..<nsRange.lowerBound]) if !beforeText.isEmpty {
if !beforeText.isEmpty { var beforeStyle = AttributeContainer()
var beforeStyle = AttributeContainer() beforeStyle.foregroundColor = baseColor
beforeStyle.foregroundColor = baseColor beforeStyle.font = isSelf
beforeStyle.font = isSelf ? .system(size: 14, weight: .bold, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced) : .system(size: 14, design: .monospaced)
: .system(size: 14, design: .monospaced) if isMentioned {
if isMentioned { beforeStyle.font = beforeStyle.font?.bold()
beforeStyle.font = beforeStyle.font?.bold()
}
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
} }
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
} }
// Add styled match // Add styled match
@@ -3573,10 +3380,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString(matchText).mergingAttributes(matchStyle)) result.append(AttributedString(matchText).mergingAttributes(matchStyle))
} }
} }
// Advance lastEnd safely in case of overlaps lastEnd = nsRange.upperBound
if lastEnd < nsRange.upperBound {
lastEnd = nsRange.upperBound
}
} }
} }
@@ -3595,15 +3399,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// Add timestamp at the end (hide while PoW mining) // Add timestamp at the end (smaller, light grey)
let hideTimestamp = (message.isMiningPow ?? false) let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
if !hideTimestamp { var timestampStyle = AttributeContainer()
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") timestampStyle.foregroundColor = Color.gray.opacity(0.7)
var timestampStyle = AttributeContainer() timestampStyle.font = .system(size: 10, design: .monospaced)
timestampStyle.foregroundColor = Color.gray.opacity(0.7) result.append(timestamp.mergingAttributes(timestampStyle))
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
} else { } else {
// System message // System message
var contentStyle = AttributeContainer() var contentStyle = AttributeContainer()
@@ -3677,23 +3478,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Regular expression to find @mentions // Regular expression to find @mentions
let pattern = "@([\\p{L}0-9_]+)" let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = contentText as NSString let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var lastEndIndex = contentText.startIndex var lastEndIndex = contentText.startIndex
for match in matches { for match in matches {
// Add text before the mention // Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) { if let range = Range(match.range(at: 0), in: contentText) {
if lastEndIndex < range.lowerBound { let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) if !beforeText.isEmpty {
if !beforeText.isEmpty { var normalStyle = AttributeContainer()
var normalStyle = AttributeContainer() normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.font = .system(size: 14, design: .monospaced) normalStyle.foregroundColor = isDark ? Color.white : Color.black
normalStyle.foregroundColor = isDark ? Color.white : Color.black processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
} }
// Add the mention with highlight // Add the mention with highlight
@@ -3703,7 +3500,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
mentionStyle.foregroundColor = Color.orange mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle)) processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound } lastEndIndex = range.upperBound
} }
} }
@@ -4901,9 +4698,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames // Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = content as NSString let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let nsLen = nsContent.length
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var mentions: [String] = [] var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames() let peerNicknames = meshService.getPeerNicknames()
@@ -5038,56 +4833,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func sendPublicRaw(_ content: String) { func sendPublicRaw(_ content: String) {
if case .location(let ch) = activeChannel { if case .location(let ch) = activeChannel {
Task { @MainActor in Task { @MainActor in
await mineAndSendGeohashEvent(content: content, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date()) 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)
}
} }
return return
} }
// Default: send over mesh // Default: send over mesh
meshService.sendMessage(content, mentions: []) 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) // MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@@ -5996,12 +5765,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isGeo && finalMessage.sender != "system" { if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] var arr = geoTimelines[gh] ?? []
// Dedup by message ID before appending to per-geohash timeline arr.append(finalMessage)
if !arr.contains(where: { $0.id == finalMessage.id }) { if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
arr.append(finalMessage) geoTimelines[gh] = arr
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
}
} }
} }
+4 -73
View File
@@ -165,8 +165,6 @@ struct ContentView: View {
} }
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil }, get: { viewModel.showingFingerprintFor != nil },
@@ -281,10 +279,8 @@ struct ContentView: View {
} }
}() }()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ForEach(items, id: \.uiID) { item in
ForEach(filteredItems, id: \.uiID) { item in
let message = item.message let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
@@ -303,21 +299,10 @@ struct ContentView: View {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
let formatted = viewModel.formatMessageAsText(message, colorScheme: colorScheme) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
let prefixAttr = viewModel.formatSenderPrefix(message, colorScheme: colorScheme) .fixedSize(horizontal: false, vertical: true)
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) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .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 // Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname, if message.isPrivate && message.sender == viewModel.nickname,
@@ -1197,13 +1182,6 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(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)) .background(backgroundColor.opacity(0.95))
} }
@@ -1398,53 +1376,6 @@ 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 // MARK: - Helper Views
// Rounded payment chip button // Rounded payment chip button
+15 -10
View File
@@ -304,17 +304,22 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
let subtitleFull: String = { HStack(spacing: 0) {
if let name = subtitleName, !name.isEmpty { Text(subtitlePrefix)
return subtitlePrefix + "" + name .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)
.lineLimit(1)
.truncationMode(.tail)
}
} }
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
} }
Spacer() Spacer()
if isSelected { if isSelected {
-87
View File
@@ -1,87 +0,0 @@
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
}
}
+13 -13
View File
@@ -2,17 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
</dict> </dict>
</plist> </plist>
-32
View File
@@ -1,32 +0,0 @@
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)
}
}