mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:05:19 +00:00
Extract public timeline helpers and rate limiter (#869)
This commit is contained in:
@@ -124,27 +124,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Spam resilience: token buckets
|
||||
private struct TokenBucket {
|
||||
var capacity: Double
|
||||
var tokens: Double
|
||||
var refillPerSec: Double
|
||||
var lastRefill: Date
|
||||
|
||||
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
||||
let dt = now.timeIntervalSince(lastRefill)
|
||||
if dt > 0 {
|
||||
tokens = min(capacity, tokens + dt * refillPerSec)
|
||||
lastRefill = now
|
||||
}
|
||||
if tokens >= cost {
|
||||
tokens -= cost
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
|
||||
|
||||
@MainActor
|
||||
@@ -158,12 +137,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private var rateBucketsBySender: [String: TokenBucket] = [:]
|
||||
private var rateBucketsByContent: [String: TokenBucket] = [:]
|
||||
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
|
||||
private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second
|
||||
private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity
|
||||
private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second
|
||||
private var publicRateLimiter = MessageRateLimiter(
|
||||
senderCapacity: TransportConfig.uiSenderRateBucketCapacity,
|
||||
senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec,
|
||||
contentCapacity: TransportConfig.uiContentRateBucketCapacity,
|
||||
contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec
|
||||
)
|
||||
|
||||
@MainActor
|
||||
private func normalizedSenderKey(for message: BitchatMessage) -> String {
|
||||
@@ -396,13 +375,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private var torStatusAnnounced = false
|
||||
private var torProgressCancellable: AnyCancellable?
|
||||
private var lastTorProgressAnnounced = -1
|
||||
// Queue geohash-only system messages if user isn't on a location channel yet
|
||||
private var pendingGeohashSystemMessages: [String] = []
|
||||
// Track whether a Tor restart is pending so we only announce
|
||||
// "tor restarted" after an actual restart, not the first launch.
|
||||
private var torRestartPending: Bool = false
|
||||
// Ensure we set up DM subscription only once per app session
|
||||
private var nostrHandlersSetup: Bool = false
|
||||
private var geoChannelCoordinator: GeoChannelCoordinator?
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
@@ -433,13 +411,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@Published var isAppInfoPresented: Bool = false
|
||||
@Published var showScreenshotPrivacyWarning: Bool = false
|
||||
|
||||
// Messages are naturally ephemeral - no persistent storage
|
||||
// Persist mesh public timeline across channel switches
|
||||
private var meshTimeline: [BitchatMessage] = []
|
||||
private let meshTimelineCap = TransportConfig.meshTimelineCap
|
||||
// Persist per-geohash public timelines across switches
|
||||
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
|
||||
private let geoTimelineCap = TransportConfig.geoTimelineCap
|
||||
private var timelineStore = PublicTimelineStore(
|
||||
meshCap: TransportConfig.meshTimelineCap,
|
||||
geohashCap: TransportConfig.geoTimelineCap
|
||||
)
|
||||
// Channel activity tracking for background nudges
|
||||
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||||
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||||
@@ -658,14 +633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
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))
|
||||
if TorManager.shared.isForeground() {
|
||||
self.beginGeohashSampling(for: union)
|
||||
} else {
|
||||
self.endGeohashSampling()
|
||||
}
|
||||
self.geoChannelCoordinator?.refreshSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -682,72 +650,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe location channel selection
|
||||
LocationChannelManager.shared.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
guard let self = self else { return }
|
||||
Task { @MainActor in
|
||||
self.switchLocationChannel(to: channel)
|
||||
}
|
||||
geoChannelCoordinator = GeoChannelCoordinator(
|
||||
onChannelSwitch: { [weak self] channel in
|
||||
self?.switchLocationChannel(to: channel)
|
||||
},
|
||||
beginSampling: { [weak self] geohashes in
|
||||
self?.beginGeohashSampling(for: geohashes)
|
||||
},
|
||||
endSampling: { [weak self] in
|
||||
self?.endGeohashSampling()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// Initialize with current selection
|
||||
Task { @MainActor in
|
||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||
}
|
||||
|
||||
// Foreground-only: sample nearby geohashes + bookmarks (disabled in background)
|
||||
LocationChannelManager.shared.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
guard let self = self else { return }
|
||||
let regional = channels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
if TorManager.shared.isForeground() {
|
||||
self.beginGeohashSampling(for: union)
|
||||
} else {
|
||||
self.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 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
|
||||
if TorManager.shared.isForeground() {
|
||||
self.beginGeohashSampling(for: union)
|
||||
} else {
|
||||
self.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Kick off initial sampling if we have regional channels or bookmarks (foreground only)
|
||||
do {
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
if !union.isEmpty && TorManager.shared.isForeground() {
|
||||
Task { @MainActor in self.beginGeohashSampling(for: union) }
|
||||
}
|
||||
}
|
||||
// Refresh channels once when authorized to seed sampling
|
||||
LocationChannelManager.shared.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { state in
|
||||
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
|
||||
@@ -1504,27 +1417,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
// Add to main messages immediately for user feedback
|
||||
messages.append(message)
|
||||
timelineStore.append(message, to: activeChannel)
|
||||
refreshVisibleMessages(from: activeChannel)
|
||||
|
||||
// Update content LRU for near-dup detection
|
||||
let ckey = normalizedContentKey(message.content)
|
||||
recordContentKey(ckey, timestamp: message.timestamp)
|
||||
|
||||
// Persist to channel-specific timelines
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(message)
|
||||
if arr.count > geoTimelineCap {
|
||||
arr = Array(arr.suffix(geoTimelineCap))
|
||||
}
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
|
||||
trimMessagesIfNeeded()
|
||||
|
||||
// UI updates automatically via @Published var messages
|
||||
@@ -1611,7 +1510,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
processedNostrEventOrder.removeAll()
|
||||
switch channel {
|
||||
case .mesh:
|
||||
messages = meshTimeline
|
||||
refreshVisibleMessages(from: .mesh)
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
@@ -1620,18 +1519,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
stopGeoParticipantsTimer()
|
||||
geohashPeople = []
|
||||
teleportedGeo.removeAll()
|
||||
case .location(let ch):
|
||||
// Persist the cleaned/sorted timeline for this geohash
|
||||
let deduped = geoTimelines[ch.geohash]?.cleanedAndDeduped() ?? []
|
||||
geoTimelines[ch.geohash] = deduped
|
||||
messages = deduped
|
||||
case .location:
|
||||
refreshVisibleMessages(from: channel)
|
||||
}
|
||||
// If switching to a location channel, flush any pending geohash-only system messages
|
||||
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
|
||||
for m in pendingGeohashSystemMessages {
|
||||
addPublicSystemMessage(m)
|
||||
if case .location = channel {
|
||||
for content in timelineStore.drainPendingGeohashSystemMessages() {
|
||||
addPublicSystemMessage(content)
|
||||
}
|
||||
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
|
||||
}
|
||||
// Unsubscribe previous
|
||||
if let sub = geoSubscriptionID {
|
||||
@@ -2040,26 +1935,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Remove their public messages from current geohash timeline and visible list
|
||||
if let gh = currentGeohash {
|
||||
if var arr = geoTimelines[gh] {
|
||||
arr.removeAll { msg in
|
||||
if let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat {
|
||||
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
|
||||
}
|
||||
return false
|
||||
}
|
||||
geoTimelines[gh] = arr
|
||||
let predicate: (BitchatMessage) -> Bool = { [self] msg in
|
||||
guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false }
|
||||
if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex }
|
||||
return false
|
||||
}
|
||||
// Also filter currently bound messages if we are in geohash channel
|
||||
switch activeChannel {
|
||||
case .location:
|
||||
messages.removeAll { msg in
|
||||
if let spid = msg.senderPeerID , spid.isGeoDM || spid.isGeoChat {
|
||||
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
|
||||
}
|
||||
return false
|
||||
}
|
||||
case .mesh:
|
||||
break
|
||||
timelineStore.removeMessages(in: gh, where: predicate)
|
||||
if case .location = activeChannel {
|
||||
messages.removeAll(where: predicate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2071,7 +1954,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
// Remove mapping keys pointing to this pubkey to avoid accidental resolution
|
||||
for (k, v) in nostrKeyMapping where v.lowercased() == hex { nostrKeyMapping.removeValue(forKey: k) }
|
||||
for (key, value) in self.nostrKeyMapping where value.lowercased() == hex {
|
||||
self.nostrKeyMapping.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
addSystemMessage(
|
||||
String(
|
||||
@@ -2187,7 +2072,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
Task { @MainActor in
|
||||
lastGeoNotificationAt[gh] = now
|
||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||
var arr = geoTimelines[gh] ?? []
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
@@ -2205,12 +2089,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if !arr.contains(where: { $0.id == msg.id }) {
|
||||
arr.append(msg)
|
||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||
geoTimelines[gh] = arr
|
||||
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2613,19 +2494,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
senderPeerID: senderPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
messages.append(message)
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(message)
|
||||
if arr.count > geoTimelineCap {
|
||||
arr = Array(arr.suffix(geoTimelineCap))
|
||||
}
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
timelineStore.append(message, to: activeChannel)
|
||||
messages = timelineStore.messages(for: activeChannel)
|
||||
trimMessagesIfNeeded()
|
||||
}
|
||||
|
||||
@@ -2762,19 +2632,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
removedMessage = messages.remove(at: idx)
|
||||
}
|
||||
|
||||
meshTimeline.removeAll { $0.id == messageID }
|
||||
|
||||
for key in Array(geoTimelines.keys) {
|
||||
var entries = geoTimelines[key] ?? []
|
||||
if let idx = entries.firstIndex(where: { $0.id == messageID }) {
|
||||
removedMessage = removedMessage ?? entries[idx]
|
||||
entries.remove(at: idx)
|
||||
if entries.isEmpty {
|
||||
geoTimelines.removeValue(forKey: key)
|
||||
} else {
|
||||
geoTimelines[key] = entries
|
||||
}
|
||||
}
|
||||
if let storeRemoved = timelineStore.removeMessage(withID: messageID) {
|
||||
removedMessage = removedMessage ?? storeRemoved
|
||||
}
|
||||
|
||||
var chats = privateChats
|
||||
@@ -4360,6 +4219,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshVisibleMessages(from channel: ChannelID? = nil) {
|
||||
let target = channel ?? activeChannel
|
||||
messages = timelineStore.messages(for: target)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
if let spid = message.senderPeerID {
|
||||
@@ -4388,16 +4253,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return getPeerPaletteColor(for: peerID, isDark: isDark)
|
||||
}
|
||||
|
||||
private func trimMeshTimelineIfNeeded() {
|
||||
if meshTimeline.count > meshTimelineCap {
|
||||
meshTimeline = Array(meshTimeline.suffix(meshTimelineCap))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer List Minimal-Distance Palette
|
||||
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
||||
// MARK: - Peer Palette Coordination
|
||||
private let meshPalette = MinimalDistancePalette(config: .mesh)
|
||||
private let nostrPalette = MinimalDistancePalette(config: .nostr)
|
||||
|
||||
@MainActor
|
||||
private func meshSeed(for peerID: PeerID) -> String {
|
||||
@@ -4409,272 +4267,66 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
|
||||
// Ensure palette up to date for current peer set and seeds
|
||||
rebuildPeerPaletteIfNeeded()
|
||||
if peerID == meshService.myPeerID {
|
||||
return .orange
|
||||
}
|
||||
|
||||
let entry = (isDark ? peerPaletteDark[peerID.id] : peerPaletteLight[peerID.id])
|
||||
let orange = Color.orange
|
||||
if peerID == meshService.myPeerID { return orange }
|
||||
let saturation: Double = isDark ? 0.80 : 0.70
|
||||
let baseBrightness: Double = isDark ? 0.75 : 0.45
|
||||
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
|
||||
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
|
||||
return color
|
||||
}
|
||||
// Fallback to seed color if not in palette (e.g., transient)
|
||||
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildPeerPaletteIfNeeded() {
|
||||
// Build current peer->seed map (excluding self)
|
||||
private func currentMeshPaletteSeeds() -> [String: String] {
|
||||
let myID = meshService.myPeerID
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in allPeers where p.peerID != myID {
|
||||
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID)
|
||||
var seeds: [String: String] = [:]
|
||||
for peer in allPeers where peer.peerID != myID {
|
||||
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
|
||||
}
|
||||
// If seeds unchanged and palette exists for both themes, skip
|
||||
if currentSeeds == peerPaletteSeeds,
|
||||
peerPaletteLight.keys.count == currentSeeds.count,
|
||||
peerPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
peerPaletteSeeds = currentSeeds
|
||||
|
||||
// Generate evenly spaced hue slots avoiding self-orange range
|
||||
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
|
||||
let avoidCenter = 30.0 / 360.0
|
||||
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
|
||||
var slots: [Double] = []
|
||||
for i in 0..<slotCount {
|
||||
let hue = Double(i) / Double(slotCount)
|
||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||
}
|
||||
|
||||
// Helper to compute circular distance
|
||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||
let d = abs(a - b)
|
||||
return d > 0.5 ? 1.0 - d : d
|
||||
}
|
||||
|
||||
// Assign slots to peers to maximize minimal distance, deterministically
|
||||
let peers = currentSeeds.keys.sorted() // stable order
|
||||
// Preferred slot index by seed (wrapping to available slots)
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = (currentSeeds[id] ?? id).djb2()
|
||||
// Map to available slot range deterministically
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
func assign(for seeds: [String: String]) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
// Keep previous assignments if still valid to minimize churn
|
||||
let prev = peerPaletteLight.isEmpty ? peerPaletteDark : peerPaletteLight
|
||||
for (id, entry) in prev {
|
||||
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(slots[entry.slot])
|
||||
}
|
||||
}
|
||||
|
||||
// First ring assignment using free slots
|
||||
let unassigned = peers.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
// If a preferred slot free, take it
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||
mapping[id] = (preferred, 0, slots[preferred])
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(slots[preferred])
|
||||
continue
|
||||
}
|
||||
// Choose free slot maximizing minimal distance to used hues
|
||||
var bestSlot: Int? = nil
|
||||
var bestScore: Double = -1
|
||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||
let hue = slots[sIdx]
|
||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||
// Bias toward preferred index for stability
|
||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDist + 0.05 * bias
|
||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||
}
|
||||
if let s = bestSlot {
|
||||
mapping[id] = (s, 0, slots[s])
|
||||
usedSlots.insert(s)
|
||||
usedHues.append(slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow peers: assign additional rings by reusing slots with stable preference
|
||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||
if !stillUnassigned.isEmpty {
|
||||
for (idx, id) in stillUnassigned.enumerated() {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
// Spread over slots by rotating from preferred with a golden-step
|
||||
let goldenStep = 7 // small prime step for dispersion
|
||||
let s = (preferred + idx * goldenStep) % slots.count
|
||||
mapping[id] = (s, 1, slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
|
||||
let mapping = assign(for: currentSeeds)
|
||||
peerPaletteLight = mapping
|
||||
peerPaletteDark = mapping
|
||||
return seeds
|
||||
}
|
||||
|
||||
// MARK: - Nostr People Minimal-Distance Palette (same algo)
|
||||
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
||||
|
||||
@MainActor
|
||||
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||||
rebuildNostrPaletteIfNeeded()
|
||||
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if let me = myHex, pubkeyHexLowercased == me { return .orange }
|
||||
let saturation: Double = isDark ? 0.80 : 0.70
|
||||
let baseBrightness: Double = isDark ? 0.75 : 0.45
|
||||
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
if let myHex, pubkeyHexLowercased == myHex {
|
||||
return .orange
|
||||
}
|
||||
|
||||
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
|
||||
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
|
||||
return color
|
||||
}
|
||||
// Fallback to seed color if not in palette (e.g., transient)
|
||||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildNostrPaletteIfNeeded() {
|
||||
// Build seeds map from currently visible geohash people (excluding self)
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let people = visibleGeohashPeople()
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in people where p.id != myHex { currentSeeds[p.id] = "nostr:" + p.id }
|
||||
|
||||
if currentSeeds == nostrPaletteSeeds,
|
||||
nostrPaletteLight.keys.count == currentSeeds.count,
|
||||
nostrPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
|
||||
var seeds: [String: String] = [:]
|
||||
let excluded = myHex ?? ""
|
||||
for person in visibleGeohashPeople() where person.id != excluded {
|
||||
seeds[person.id] = "nostr:" + person.id
|
||||
}
|
||||
nostrPaletteSeeds = currentSeeds
|
||||
return seeds
|
||||
}
|
||||
|
||||
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
|
||||
let avoidCenter = 30.0 / 360.0
|
||||
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
|
||||
var slots: [Double] = []
|
||||
for i in 0..<slotCount {
|
||||
let hue = Double(i) / Double(slotCount)
|
||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||
slots.append(hue)
|
||||
@MainActor
|
||||
private func currentGeohashIdentityHex() -> String? {
|
||||
if case .location(let channel) = LocationChannelManager.shared.selectedChannel,
|
||||
let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
return identity.publicKeyHex.lowercased()
|
||||
}
|
||||
if slots.isEmpty {
|
||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||
}
|
||||
|
||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||
let d = abs(a - b)
|
||||
return d > 0.5 ? 1.0 - d : d
|
||||
}
|
||||
|
||||
let peers = currentSeeds.keys.sorted()
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = (currentSeeds[id] ?? id).djb2()
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
let prev = nostrPaletteLight.isEmpty ? nostrPaletteDark : nostrPaletteLight
|
||||
for (id, entry) in prev {
|
||||
if peers.contains(id), entry.slot < slots.count {
|
||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(slots[entry.slot])
|
||||
}
|
||||
}
|
||||
|
||||
let unassigned = peers.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||
mapping[id] = (preferred, 0, slots[preferred])
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(slots[preferred])
|
||||
continue
|
||||
}
|
||||
var bestSlot: Int? = nil
|
||||
var bestScore: Double = -1
|
||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||
let hue = slots[sIdx]
|
||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDist + 0.05 * bias
|
||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||
}
|
||||
if let s = bestSlot {
|
||||
mapping[id] = (s, 0, slots[s])
|
||||
usedSlots.insert(s)
|
||||
usedHues.append(slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||
if !stillUnassigned.isEmpty {
|
||||
for (idx, id) in stillUnassigned.enumerated() {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
let goldenStep = 7
|
||||
let s = (preferred + idx * goldenStep) % slots.count
|
||||
mapping[id] = (s, 1, slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
nostrPaletteLight = mapping
|
||||
nostrPaletteDark = mapping
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear the current public channel's timeline (visible + persistent buffer)
|
||||
@MainActor
|
||||
func clearCurrentPublicTimeline() {
|
||||
// Clear messages from current timeline
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
messages.removeAll()
|
||||
meshTimeline.removeAll()
|
||||
case .location(let ch):
|
||||
messages.removeAll()
|
||||
geoTimelines[ch.geohash] = []
|
||||
}
|
||||
messages.removeAll()
|
||||
timelineStore.clear(channel: activeChannel)
|
||||
|
||||
// Delete associated media files (images, voice notes, files) in background
|
||||
// Only delete from current chat to avoid removing private chat media
|
||||
@@ -5520,13 +5172,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
// Persist to mesh timeline
|
||||
meshTimeline.append(systemMessage)
|
||||
trimMeshTimelineIfNeeded()
|
||||
// Only show inline if mesh is the active channel
|
||||
if case .mesh = activeChannel {
|
||||
messages.append(systemMessage)
|
||||
}
|
||||
timelineStore.append(systemMessage, to: .mesh)
|
||||
refreshVisibleMessages()
|
||||
trimMessagesIfNeeded()
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -5540,20 +5188,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
// Append to current visible messages
|
||||
messages.append(systemMessage)
|
||||
timelineStore.append(systemMessage, to: activeChannel)
|
||||
refreshVisibleMessages(from: activeChannel)
|
||||
// Track the content key so relayed copies of the same system-style message are ignored
|
||||
let contentKey = normalizedContentKey(systemMessage.content)
|
||||
recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||||
// Persist into the backing store for the active channel to survive rebinds
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(systemMessage)
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(systemMessage)
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
trimMessagesIfNeeded()
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -5564,7 +5204,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
addPublicSystemMessage(content)
|
||||
} else {
|
||||
// Not on a location channel yet: queue to show when user switches
|
||||
pendingGeohashSystemMessages.append(content)
|
||||
timelineStore.queueGeohashSystemMessage(content)
|
||||
}
|
||||
}
|
||||
// Send a public message without adding a local user echo.
|
||||
@@ -6369,14 +6009,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if shouldRateLimit {
|
||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||
let contentKey = normalizedContentKey(finalMessage.content)
|
||||
let now = Date()
|
||||
var sBucket = rateBucketsBySender[senderKey] ?? TokenBucket(capacity: senderBucketCapacity, tokens: senderBucketCapacity, refillPerSec: senderBucketRefill, lastRefill: now)
|
||||
let senderAllowed = sBucket.allow(now: now)
|
||||
rateBucketsBySender[senderKey] = sBucket
|
||||
var cBucket = rateBucketsByContent[contentKey] ?? TokenBucket(capacity: contentBucketCapacity, tokens: contentBucketCapacity, refillPerSec: contentBucketRefill, lastRefill: now)
|
||||
let contentAllowed = cBucket.allow(now: now)
|
||||
rateBucketsByContent[contentKey] = cBucket
|
||||
if !(senderAllowed && contentAllowed) { return }
|
||||
if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return }
|
||||
}
|
||||
|
||||
// Size cap: drop extremely large public messages early
|
||||
@@ -6384,22 +6017,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Persist mesh messages to mesh timeline always
|
||||
if !isGeo && finalMessage.sender != "system" {
|
||||
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
|
||||
meshTimeline.append(finalMessage)
|
||||
trimMeshTimelineIfNeeded()
|
||||
}
|
||||
timelineStore.append(finalMessage, to: .mesh)
|
||||
}
|
||||
|
||||
// Persist geochat messages to per-geohash timeline
|
||||
if isGeo && finalMessage.sender != "system" {
|
||||
if let gh = currentGeohash {
|
||||
var arr = geoTimelines[gh] ?? []
|
||||
// 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
|
||||
}
|
||||
_ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// GeoChannelCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// Centralizes Combine wiring for location channel selection and sampling.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Tor
|
||||
|
||||
@MainActor
|
||||
final class GeoChannelCoordinator {
|
||||
private let locationManager: LocationChannelManager
|
||||
private let bookmarksStore: GeohashBookmarksStore
|
||||
private let torManager: TorManager
|
||||
|
||||
private let onChannelSwitch: (ChannelID) -> Void
|
||||
private let beginSampling: ([String]) -> Void
|
||||
private let endSampling: () -> Void
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var regionalGeohashes: [String] = []
|
||||
private var bookmarkedGeohashes: [String] = []
|
||||
|
||||
init(
|
||||
locationManager: LocationChannelManager? = nil,
|
||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||
torManager: TorManager? = nil,
|
||||
onChannelSwitch: @escaping (ChannelID) -> Void,
|
||||
beginSampling: @escaping ([String]) -> Void,
|
||||
endSampling: @escaping () -> Void
|
||||
) {
|
||||
self.locationManager = locationManager ?? LocationChannelManager.shared
|
||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
||||
self.torManager = torManager ?? TorManager.shared
|
||||
self.onChannelSwitch = onChannelSwitch
|
||||
self.beginSampling = beginSampling
|
||||
self.endSampling = endSampling
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
|
||||
bookmarkedGeohashes = bookmarksStore.bookmarks
|
||||
|
||||
locationManager.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.onChannelSwitch(channel)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationManager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
guard let self else { return }
|
||||
self.regionalGeohashes = channels.map { $0.geohash }
|
||||
self.updateSampling()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
bookmarksStore.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bookmarks in
|
||||
guard let self else { return }
|
||||
self.bookmarkedGeohashes = bookmarks
|
||||
self.updateSampling()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationManager.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self, state == .authorized else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task { @MainActor in
|
||||
self.onChannelSwitch(self.locationManager.selectedChannel)
|
||||
}
|
||||
updateSampling()
|
||||
}
|
||||
|
||||
private func updateSampling() {
|
||||
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
||||
Task { @MainActor in
|
||||
guard !union.isEmpty else {
|
||||
endSampling()
|
||||
return
|
||||
}
|
||||
if torManager.isForeground() {
|
||||
beginSampling(union)
|
||||
} else {
|
||||
endSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshSampling() {
|
||||
updateSampling()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// MessageRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles per-sender and per-content token buckets for public message intake.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MessageRateLimiter {
|
||||
private struct TokenBucket {
|
||||
var capacity: Double
|
||||
var tokens: Double
|
||||
var refillPerSec: Double
|
||||
var lastRefill: Date
|
||||
|
||||
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
||||
let dt = now.timeIntervalSince(lastRefill)
|
||||
if dt > 0 {
|
||||
tokens = min(capacity, tokens + dt * refillPerSec)
|
||||
lastRefill = now
|
||||
}
|
||||
if tokens >= cost {
|
||||
tokens -= cost
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var senderBuckets: [String: TokenBucket] = [:]
|
||||
private var contentBuckets: [String: TokenBucket] = [:]
|
||||
|
||||
private let senderCapacity: Double
|
||||
private let senderRefill: Double
|
||||
private let contentCapacity: Double
|
||||
private let contentRefill: Double
|
||||
|
||||
init(
|
||||
senderCapacity: Double,
|
||||
senderRefillPerSec: Double,
|
||||
contentCapacity: Double,
|
||||
contentRefillPerSec: Double
|
||||
) {
|
||||
self.senderCapacity = senderCapacity
|
||||
self.senderRefill = senderRefillPerSec
|
||||
self.contentCapacity = contentCapacity
|
||||
self.contentRefill = contentRefillPerSec
|
||||
}
|
||||
|
||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
let senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
|
||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||
capacity: contentCapacity,
|
||||
tokens: contentCapacity,
|
||||
refillPerSec: contentRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
let contentAllowed = contentBucket.allow(now: now)
|
||||
contentBuckets[contentKey] = contentBucket
|
||||
|
||||
return senderAllowed && contentAllowed
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
senderBuckets.removeAll()
|
||||
contentBuckets.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// MinimalDistancePalette.swift
|
||||
// bitchat
|
||||
//
|
||||
// Lightweight palette generator that keeps peer colors evenly spaced.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class MinimalDistancePalette {
|
||||
struct Config {
|
||||
let slotCount: Int
|
||||
let avoidCenterHue: Double
|
||||
let avoidHueDelta: Double
|
||||
let saturationLight: Double
|
||||
let saturationDark: Double
|
||||
let baseBrightnessLight: Double
|
||||
let baseBrightnessDark: Double
|
||||
let ringBrightnessDeltaLight: Double
|
||||
let ringBrightnessDeltaDark: Double
|
||||
let preferredBiasWeight: Double
|
||||
let goldenStep: Int
|
||||
|
||||
init(
|
||||
slotCount: Int,
|
||||
avoidCenterHue: Double,
|
||||
avoidHueDelta: Double,
|
||||
saturationLight: Double,
|
||||
saturationDark: Double,
|
||||
baseBrightnessLight: Double,
|
||||
baseBrightnessDark: Double,
|
||||
ringBrightnessDeltaLight: Double,
|
||||
ringBrightnessDeltaDark: Double,
|
||||
preferredBiasWeight: Double = 0.05,
|
||||
goldenStep: Int = 7
|
||||
) {
|
||||
self.slotCount = slotCount
|
||||
self.avoidCenterHue = avoidCenterHue
|
||||
self.avoidHueDelta = avoidHueDelta
|
||||
self.saturationLight = saturationLight
|
||||
self.saturationDark = saturationDark
|
||||
self.baseBrightnessLight = baseBrightnessLight
|
||||
self.baseBrightnessDark = baseBrightnessDark
|
||||
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
|
||||
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
|
||||
self.preferredBiasWeight = preferredBiasWeight
|
||||
self.goldenStep = goldenStep
|
||||
}
|
||||
}
|
||||
|
||||
private struct Entry {
|
||||
let slot: Int
|
||||
let ring: Int
|
||||
let hue: Double
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private var currentSeeds: [String: String] = [:]
|
||||
private var entries: [String: Entry] = [:]
|
||||
private var previousEntries: [String: Entry] = [:]
|
||||
|
||||
init(config: Config) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func ensurePalette(for seeds: [String: String]) {
|
||||
guard seeds != currentSeeds || entries.count != seeds.count else { return }
|
||||
previousEntries = entries
|
||||
currentSeeds = seeds
|
||||
rebuildEntries()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func color(for identifier: String, isDark: Bool) -> Color? {
|
||||
guard let entry = entries[identifier] else { return nil }
|
||||
let saturation = isDark ? config.saturationDark : config.saturationLight
|
||||
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
|
||||
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
|
||||
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func reset() {
|
||||
currentSeeds.removeAll()
|
||||
entries.removeAll()
|
||||
previousEntries.removeAll()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildEntries() {
|
||||
guard !currentSeeds.isEmpty else {
|
||||
entries.removeAll()
|
||||
return
|
||||
}
|
||||
|
||||
let slotCount = max(8, config.slotCount)
|
||||
var slots: [Double] = []
|
||||
for idx in 0..<slotCount {
|
||||
let hue = Double(idx) / Double(slotCount)
|
||||
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
|
||||
continue
|
||||
}
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
for idx in 0..<slotCount {
|
||||
slots.append(Double(idx) / Double(slotCount))
|
||||
}
|
||||
}
|
||||
|
||||
func circularDistance(_ a: Double, _ b: Double) -> Double {
|
||||
let diff = abs(a - b)
|
||||
return diff > 0.5 ? 1.0 - diff : diff
|
||||
}
|
||||
|
||||
let peerIDs = currentSeeds.keys.sorted()
|
||||
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
|
||||
let seed = currentSeeds[id] ?? id
|
||||
let hash = seed.djb2()
|
||||
let index = Int(hash % UInt64(slots.count))
|
||||
return (id, index)
|
||||
})
|
||||
|
||||
var mapping: [String: Entry] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
let prior = entries.isEmpty ? previousEntries : entries
|
||||
for (id, entry) in prior {
|
||||
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
|
||||
let hue = slots[entry.slot]
|
||||
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(hue)
|
||||
}
|
||||
|
||||
let unassigned = peerIDs.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
let preferred = preferredIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred), preferred < slots.count {
|
||||
let hue = slots[preferred]
|
||||
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(hue)
|
||||
continue
|
||||
}
|
||||
|
||||
var bestSlot: Int?
|
||||
var bestScore = -Double.infinity
|
||||
for slot in 0..<slots.count where !usedSlots.contains(slot) {
|
||||
let hue = slots[slot]
|
||||
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
|
||||
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDistance + config.preferredBiasWeight * bias
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestSlot = slot
|
||||
}
|
||||
}
|
||||
|
||||
if let slot = bestSlot {
|
||||
let hue = slots[slot]
|
||||
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
|
||||
usedSlots.insert(slot)
|
||||
usedHues.append(hue)
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = peerIDs.filter { mapping[$0] == nil }
|
||||
if !remaining.isEmpty {
|
||||
for (index, id) in remaining.enumerated() {
|
||||
let preferred = preferredIndex[id] ?? 0
|
||||
let slot = (preferred + index * config.goldenStep) % slots.count
|
||||
let hue = slots[slot]
|
||||
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
|
||||
}
|
||||
}
|
||||
|
||||
entries = mapping
|
||||
}
|
||||
}
|
||||
|
||||
extension MinimalDistancePalette.Config {
|
||||
static let mesh = MinimalDistancePalette.Config(
|
||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
||||
avoidCenterHue: 30.0 / 360.0,
|
||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
||||
saturationLight: 0.70,
|
||||
saturationDark: 0.80,
|
||||
baseBrightnessLight: 0.45,
|
||||
baseBrightnessDark: 0.75,
|
||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
||||
)
|
||||
|
||||
static let nostr = MinimalDistancePalette.Config(
|
||||
slotCount: TransportConfig.uiPeerPaletteSlots,
|
||||
avoidCenterHue: 30.0 / 360.0,
|
||||
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
||||
saturationLight: 0.70,
|
||||
saturationDark: 0.80,
|
||||
baseBrightnessLight: 0.45,
|
||||
baseBrightnessDark: 0.75,
|
||||
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
||||
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// PublicTimelineStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PublicTimelineStore {
|
||||
private var meshTimeline: [BitchatMessage] = []
|
||||
private var geohashTimelines: [String: [BitchatMessage]] = [:]
|
||||
private var pendingGeohashSystemMessages: [String] = []
|
||||
|
||||
private let meshCap: Int
|
||||
private let geohashCap: Int
|
||||
|
||||
init(meshCap: Int, geohashCap: Int) {
|
||||
self.meshCap = meshCap
|
||||
self.geohashCap = geohashCap
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let channel):
|
||||
append(message, toGeohash: channel.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
guard !timeline.contains(where: { $0.id == message.id }) else { return }
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline)
|
||||
geohashTimelines[geohash] = timeline
|
||||
}
|
||||
|
||||
/// Append message if absent, returning true when stored.
|
||||
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline)
|
||||
geohashTimelines[geohash] = timeline
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
return meshTimeline
|
||||
case .location(let channel):
|
||||
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
|
||||
geohashTimelines[channel.geohash] = cleaned
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
mutating func clear(channel: ChannelID) {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
meshTimeline.removeAll()
|
||||
case .location(let channel):
|
||||
geohashTimelines[channel.geohash] = []
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeMessage(withID id: String) -> BitchatMessage? {
|
||||
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
||||
return meshTimeline.remove(at: index)
|
||||
}
|
||||
|
||||
for key in Array(geohashTimelines.keys) {
|
||||
var timeline = geohashTimelines[key] ?? []
|
||||
if let index = timeline.firstIndex(where: { $0.id == id }) {
|
||||
let removed = timeline.remove(at: index)
|
||||
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
timeline.removeAll(where: predicate)
|
||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||
}
|
||||
|
||||
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
transform(&timeline)
|
||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||
}
|
||||
|
||||
mutating func queueGeohashSystemMessage(_ content: String) {
|
||||
pendingGeohashSystemMessages.append(content)
|
||||
}
|
||||
|
||||
mutating func drainPendingGeohashSystemMessages() -> [String] {
|
||||
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
|
||||
return pendingGeohashSystemMessages
|
||||
}
|
||||
|
||||
func geohashKeys() -> [String] {
|
||||
Array(geohashTimelines.keys)
|
||||
}
|
||||
|
||||
private mutating func trimMeshTimelineIfNeeded() {
|
||||
guard meshTimeline.count > meshCap else { return }
|
||||
meshTimeline = Array(meshTimeline.suffix(meshCap))
|
||||
}
|
||||
|
||||
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
|
||||
guard timeline.count > geohashCap else { return }
|
||||
timeline = Array(timeline.suffix(geohashCap))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user