mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05: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)
|
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -158,12 +137,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var rateBucketsBySender: [String: TokenBucket] = [:]
|
private var publicRateLimiter = MessageRateLimiter(
|
||||||
private var rateBucketsByContent: [String: TokenBucket] = [:]
|
senderCapacity: TransportConfig.uiSenderRateBucketCapacity,
|
||||||
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
|
senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec,
|
||||||
private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second
|
contentCapacity: TransportConfig.uiContentRateBucketCapacity,
|
||||||
private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity
|
contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec
|
||||||
private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second
|
)
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func normalizedSenderKey(for message: BitchatMessage) -> String {
|
private func normalizedSenderKey(for message: BitchatMessage) -> String {
|
||||||
@@ -396,13 +375,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private var torStatusAnnounced = false
|
private var torStatusAnnounced = false
|
||||||
private var torProgressCancellable: AnyCancellable?
|
private var torProgressCancellable: AnyCancellable?
|
||||||
private var lastTorProgressAnnounced = -1
|
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
|
// Track whether a Tor restart is pending so we only announce
|
||||||
// "tor restarted" after an actual restart, not the first launch.
|
// "tor restarted" after an actual restart, not the first launch.
|
||||||
private var torRestartPending: Bool = false
|
private var torRestartPending: Bool = false
|
||||||
// Ensure we set up DM subscription only once per app session
|
// Ensure we set up DM subscription only once per app session
|
||||||
private var nostrHandlersSetup: Bool = false
|
private var nostrHandlersSetup: Bool = false
|
||||||
|
private var geoChannelCoordinator: GeoChannelCoordinator?
|
||||||
|
|
||||||
// MARK: - Caches
|
// MARK: - Caches
|
||||||
|
|
||||||
@@ -433,13 +411,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published var isAppInfoPresented: Bool = false
|
@Published var isAppInfoPresented: Bool = false
|
||||||
@Published var showScreenshotPrivacyWarning: Bool = false
|
@Published var showScreenshotPrivacyWarning: Bool = false
|
||||||
|
|
||||||
// Messages are naturally ephemeral - no persistent storage
|
private var timelineStore = PublicTimelineStore(
|
||||||
// Persist mesh public timeline across channel switches
|
meshCap: TransportConfig.meshTimelineCap,
|
||||||
private var meshTimeline: [BitchatMessage] = []
|
geohashCap: TransportConfig.geoTimelineCap
|
||||||
private let meshTimelineCap = TransportConfig.meshTimelineCap
|
)
|
||||||
// Persist per-geohash public timelines across switches
|
|
||||||
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
|
|
||||||
private let geoTimelineCap = TransportConfig.geoTimelineCap
|
|
||||||
// Channel activity tracking for background nudges
|
// Channel activity tracking for background nudges
|
||||||
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||||||
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||||||
@@ -658,14 +633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
self.resubscribeCurrentGeohash()
|
self.resubscribeCurrentGeohash()
|
||||||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
self.geoChannelCoordinator?.refreshSampling()
|
||||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
|
||||||
let union = Array(Set(regional).union(bookmarks))
|
|
||||||
if TorManager.shared.isForeground() {
|
|
||||||
self.beginGeohashSampling(for: union)
|
|
||||||
} else {
|
|
||||||
self.endGeohashSampling()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,72 +650,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Observe location channel selection
|
geoChannelCoordinator = GeoChannelCoordinator(
|
||||||
LocationChannelManager.shared.$selectedChannel
|
onChannelSwitch: { [weak self] channel in
|
||||||
.receive(on: DispatchQueue.main)
|
self?.switchLocationChannel(to: channel)
|
||||||
.sink { [weak self] channel in
|
},
|
||||||
guard let self = self else { return }
|
beginSampling: { [weak self] geohashes in
|
||||||
Task { @MainActor in
|
self?.beginGeohashSampling(for: geohashes)
|
||||||
self.switchLocationChannel(to: channel)
|
},
|
||||||
}
|
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
|
// Track teleport flag changes to keep our own teleported marker in sync with regional status
|
||||||
LocationChannelManager.shared.$teleported
|
LocationChannelManager.shared.$teleported
|
||||||
@@ -1504,27 +1417,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to main messages immediately for user feedback
|
timelineStore.append(message, to: activeChannel)
|
||||||
messages.append(message)
|
refreshVisibleMessages(from: activeChannel)
|
||||||
|
|
||||||
// Update content LRU for near-dup detection
|
// Update content LRU for near-dup detection
|
||||||
let ckey = normalizedContentKey(message.content)
|
let ckey = normalizedContentKey(message.content)
|
||||||
recordContentKey(ckey, timestamp: message.timestamp)
|
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()
|
trimMessagesIfNeeded()
|
||||||
|
|
||||||
// UI updates automatically via @Published var messages
|
// UI updates automatically via @Published var messages
|
||||||
@@ -1611,7 +1510,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
processedNostrEventOrder.removeAll()
|
processedNostrEventOrder.removeAll()
|
||||||
switch channel {
|
switch channel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
messages = meshTimeline
|
refreshVisibleMessages(from: .mesh)
|
||||||
// Debug: log if any empty messages are present
|
// Debug: log if any empty messages are present
|
||||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||||
if emptyMesh > 0 {
|
if emptyMesh > 0 {
|
||||||
@@ -1620,18 +1519,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
stopGeoParticipantsTimer()
|
stopGeoParticipantsTimer()
|
||||||
geohashPeople = []
|
geohashPeople = []
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
case .location(let ch):
|
case .location:
|
||||||
// Persist the cleaned/sorted timeline for this geohash
|
refreshVisibleMessages(from: channel)
|
||||||
let deduped = geoTimelines[ch.geohash]?.cleanedAndDeduped() ?? []
|
|
||||||
geoTimelines[ch.geohash] = deduped
|
|
||||||
messages = deduped
|
|
||||||
}
|
}
|
||||||
// If switching to a location channel, flush any pending geohash-only system messages
|
// If switching to a location channel, flush any pending geohash-only system messages
|
||||||
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
|
if case .location = channel {
|
||||||
for m in pendingGeohashSystemMessages {
|
for content in timelineStore.drainPendingGeohashSystemMessages() {
|
||||||
addPublicSystemMessage(m)
|
addPublicSystemMessage(content)
|
||||||
}
|
}
|
||||||
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
|
|
||||||
}
|
}
|
||||||
// Unsubscribe previous
|
// Unsubscribe previous
|
||||||
if let sub = geoSubscriptionID {
|
if let sub = geoSubscriptionID {
|
||||||
@@ -2040,26 +1935,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Remove their public messages from current geohash timeline and visible list
|
// Remove their public messages from current geohash timeline and visible list
|
||||||
if let gh = currentGeohash {
|
if let gh = currentGeohash {
|
||||||
if var arr = geoTimelines[gh] {
|
let predicate: (BitchatMessage) -> Bool = { [self] msg in
|
||||||
arr.removeAll { msg in
|
guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false }
|
||||||
if let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat {
|
if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex }
|
||||||
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
|
return false
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
geoTimelines[gh] = arr
|
|
||||||
}
|
}
|
||||||
// Also filter currently bound messages if we are in geohash channel
|
timelineStore.removeMessages(in: gh, where: predicate)
|
||||||
switch activeChannel {
|
if case .location = activeChannel {
|
||||||
case .location:
|
messages.removeAll(where: predicate)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2071,7 +1954,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove mapping keys pointing to this pubkey to avoid accidental resolution
|
// 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(
|
addSystemMessage(
|
||||||
String(
|
String(
|
||||||
@@ -2187,7 +2072,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
lastGeoNotificationAt[gh] = now
|
lastGeoNotificationAt[gh] = now
|
||||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
// 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 senderSuffix = String(event.pubkey.suffix(4))
|
||||||
let nick = geoNicknames[event.pubkey.lowercased()]
|
let nick = geoNicknames[event.pubkey.lowercased()]
|
||||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||||
@@ -2205,12 +2089,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
senderPeerID: PeerID(nostr: event.pubkey),
|
senderPeerID: PeerID(nostr: event.pubkey),
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
if !arr.contains(where: { $0.id == msg.id }) {
|
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
|
||||||
arr.append(msg)
|
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
|
||||||
geoTimelines[gh] = arr
|
|
||||||
}
|
}
|
||||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2613,19 +2494,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
senderPeerID: senderPeerID,
|
senderPeerID: senderPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
messages.append(message)
|
timelineStore.append(message, to: activeChannel)
|
||||||
switch activeChannel {
|
messages = timelineStore.messages(for: 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()
|
trimMessagesIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2762,19 +2632,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
removedMessage = messages.remove(at: idx)
|
removedMessage = messages.remove(at: idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
meshTimeline.removeAll { $0.id == messageID }
|
if let storeRemoved = timelineStore.removeMessage(withID: messageID) {
|
||||||
|
removedMessage = removedMessage ?? storeRemoved
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var chats = privateChats
|
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
|
@MainActor
|
||||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||||
if let spid = message.senderPeerID {
|
if let spid = message.senderPeerID {
|
||||||
@@ -4388,16 +4253,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return getPeerPaletteColor(for: peerID, isDark: isDark)
|
return getPeerPaletteColor(for: peerID, isDark: isDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trimMeshTimelineIfNeeded() {
|
// MARK: - Peer Palette Coordination
|
||||||
if meshTimeline.count > meshTimelineCap {
|
private let meshPalette = MinimalDistancePalette(config: .mesh)
|
||||||
meshTimeline = Array(meshTimeline.suffix(meshTimelineCap))
|
private let nostrPalette = MinimalDistancePalette(config: .nostr)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func meshSeed(for peerID: PeerID) -> String {
|
private func meshSeed(for peerID: PeerID) -> String {
|
||||||
@@ -4409,272 +4267,66 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
|
private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
|
||||||
// Ensure palette up to date for current peer set and seeds
|
if peerID == meshService.myPeerID {
|
||||||
rebuildPeerPaletteIfNeeded()
|
return .orange
|
||||||
|
}
|
||||||
|
|
||||||
let entry = (isDark ? peerPaletteDark[peerID.id] : peerPaletteLight[peerID.id])
|
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
|
||||||
let orange = Color.orange
|
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
|
||||||
if peerID == meshService.myPeerID { return orange }
|
return color
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
// Fallback to seed color if not in palette (e.g., transient)
|
|
||||||
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func rebuildPeerPaletteIfNeeded() {
|
private func currentMeshPaletteSeeds() -> [String: String] {
|
||||||
// Build current peer->seed map (excluding self)
|
|
||||||
let myID = meshService.myPeerID
|
let myID = meshService.myPeerID
|
||||||
var currentSeeds: [String: String] = [:]
|
var seeds: [String: String] = [:]
|
||||||
for p in allPeers where p.peerID != myID {
|
for peer in allPeers where peer.peerID != myID {
|
||||||
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID)
|
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
|
||||||
}
|
}
|
||||||
// If seeds unchanged and palette exists for both themes, skip
|
return seeds
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
@MainActor
|
||||||
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||||||
rebuildNostrPaletteIfNeeded()
|
let myHex = currentGeohashIdentityHex()
|
||||||
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
if let myHex, pubkeyHexLowercased == myHex {
|
||||||
let myHex: String? = {
|
return .orange
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
}
|
||||||
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
|
||||||
return id.publicKeyHex.lowercased()
|
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
|
||||||
}
|
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
|
||||||
return nil
|
return color
|
||||||
}()
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
// Fallback to seed color if not in palette (e.g., transient)
|
|
||||||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func rebuildNostrPaletteIfNeeded() {
|
private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
|
||||||
// Build seeds map from currently visible geohash people (excluding self)
|
var seeds: [String: String] = [:]
|
||||||
let myHex: String? = {
|
let excluded = myHex ?? ""
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
for person in visibleGeohashPeople() where person.id != excluded {
|
||||||
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
seeds[person.id] = "nostr:" + person.id
|
||||||
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
|
|
||||||
}
|
}
|
||||||
nostrPaletteSeeds = currentSeeds
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
|
@MainActor
|
||||||
let avoidCenter = 30.0 / 360.0
|
private func currentGeohashIdentityHex() -> String? {
|
||||||
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
|
if case .location(let channel) = LocationChannelManager.shared.selectedChannel,
|
||||||
var slots: [Double] = []
|
let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||||
for i in 0..<slotCount {
|
return identity.publicKeyHex.lowercased()
|
||||||
let hue = Double(i) / Double(slotCount)
|
|
||||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
|
||||||
slots.append(hue)
|
|
||||||
}
|
}
|
||||||
if slots.isEmpty {
|
return nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the current public channel's timeline (visible + persistent buffer)
|
// Clear the current public channel's timeline (visible + persistent buffer)
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearCurrentPublicTimeline() {
|
func clearCurrentPublicTimeline() {
|
||||||
// Clear messages from current timeline
|
// Clear messages from current timeline
|
||||||
switch activeChannel {
|
messages.removeAll()
|
||||||
case .mesh:
|
timelineStore.clear(channel: activeChannel)
|
||||||
messages.removeAll()
|
|
||||||
meshTimeline.removeAll()
|
|
||||||
case .location(let ch):
|
|
||||||
messages.removeAll()
|
|
||||||
geoTimelines[ch.geohash] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete associated media files (images, voice notes, files) in background
|
// Delete associated media files (images, voice notes, files) in background
|
||||||
// Only delete from current chat to avoid removing private chat media
|
// Only delete from current chat to avoid removing private chat media
|
||||||
@@ -5520,13 +5172,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
// Persist to mesh timeline
|
timelineStore.append(systemMessage, to: .mesh)
|
||||||
meshTimeline.append(systemMessage)
|
refreshVisibleMessages()
|
||||||
trimMeshTimelineIfNeeded()
|
trimMessagesIfNeeded()
|
||||||
// Only show inline if mesh is the active channel
|
|
||||||
if case .mesh = activeChannel {
|
|
||||||
messages.append(systemMessage)
|
|
||||||
}
|
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5540,20 +5188,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
// Append to current visible messages
|
timelineStore.append(systemMessage, to: activeChannel)
|
||||||
messages.append(systemMessage)
|
refreshVisibleMessages(from: activeChannel)
|
||||||
// Track the content key so relayed copies of the same system-style message are ignored
|
// Track the content key so relayed copies of the same system-style message are ignored
|
||||||
let contentKey = normalizedContentKey(systemMessage.content)
|
let contentKey = normalizedContentKey(systemMessage.content)
|
||||||
recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||||||
// Persist into the backing store for the active channel to survive rebinds
|
trimMessagesIfNeeded()
|
||||||
switch activeChannel {
|
|
||||||
case .mesh:
|
|
||||||
meshTimeline.append(systemMessage)
|
|
||||||
case .location(let ch):
|
|
||||||
var arr = geoTimelines[ch.geohash] ?? []
|
|
||||||
arr.append(systemMessage)
|
|
||||||
geoTimelines[ch.geohash] = arr
|
|
||||||
}
|
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5564,7 +5204,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
addPublicSystemMessage(content)
|
addPublicSystemMessage(content)
|
||||||
} else {
|
} else {
|
||||||
// Not on a location channel yet: queue to show when user switches
|
// 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.
|
// Send a public message without adding a local user echo.
|
||||||
@@ -6369,14 +6009,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if shouldRateLimit {
|
if shouldRateLimit {
|
||||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
let contentKey = normalizedContentKey(finalMessage.content)
|
let contentKey = normalizedContentKey(finalMessage.content)
|
||||||
let now = Date()
|
if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size cap: drop extremely large public messages early
|
// Size cap: drop extremely large public messages early
|
||||||
@@ -6384,22 +6017,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Persist mesh messages to mesh timeline always
|
// Persist mesh messages to mesh timeline always
|
||||||
if !isGeo && finalMessage.sender != "system" {
|
if !isGeo && finalMessage.sender != "system" {
|
||||||
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
|
timelineStore.append(finalMessage, to: .mesh)
|
||||||
meshTimeline.append(finalMessage)
|
|
||||||
trimMeshTimelineIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist geochat messages to per-geohash timeline
|
// Persist geochat messages to per-geohash timeline
|
||||||
if isGeo && finalMessage.sender != "system" {
|
if isGeo && finalMessage.sender != "system" {
|
||||||
if let gh = currentGeohash {
|
if let gh = currentGeohash {
|
||||||
var arr = geoTimelines[gh] ?? []
|
_ = timelineStore.appendIfAbsent(finalMessage, toGeohash: 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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