Finish coordinator migration: zero ChatViewModel back-references remain

ChatPeerListCoordinator, ChatComposerCoordinator, ChatOutgoingCoordinator,
and GeoChannelCoordinator complete the migration; every coordinator now
depends on a narrow @MainActor context protocol. GeoChannelCoordinator's
three injected closures collapse into a weak context. New intent op
recordPublicActivity(forChannelKey:) keeps lastPublicActivityAt
single-writer. 15 new mock-context tests; flaky-poll deadline in the
gift-wrap dedup test raised for parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 22:13:09 +02:00
co-authored by Claude Fable 5
parent 82736c4991
commit 6091ee83ad
11 changed files with 1023 additions and 114 deletions
@@ -1,44 +1,105 @@
import BitFoundation
import Foundation
/// The narrow surface `ChatComposerCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatComposerContext: AnyObject {
// MARK: Autocomplete UI state
var autocompleteSuggestions: [String] { get set }
var autocompleteRange: NSRange? { get set }
var showAutocomplete: Bool { get set }
var selectedAutocompleteIndex: Int { get set }
/// Computes mention suggestions for the text up to the cursor.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?)
/// Replaces the matched range in `text` with the chosen suggestion.
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// The transport's own nickname (excluded from autocomplete candidates).
var meshNickname: String { get }
func meshPeerNicknames() -> [PeerID: String]
// MARK: Geohash identity (shared with the other contexts)
var geoNicknames: [String: String] { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatComposerContext {
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
// `geoNicknames`, `meshPeerNicknames()`, and
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten nested service accesses into intent-named calls.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?) {
autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition)
}
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
autocompleteService.applySuggestion(suggestion, to: text, range: range)
}
var meshNickname: String {
meshService.myNickname
}
}
@MainActor
final class ChatComposerCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatComposerContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatComposerContext) {
self.context = context
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
let peerCandidates = autocompleteCandidates()
let (suggestions, range) = viewModel.autocompleteService.getSuggestions(
let (suggestions, range) = context.autocompleteQuery(
for: text,
peers: peerCandidates,
cursorPosition: cursorPosition
)
if !suggestions.isEmpty {
viewModel.autocompleteSuggestions = suggestions
viewModel.autocompleteRange = range
viewModel.showAutocomplete = true
viewModel.selectedAutocompleteIndex = 0
context.autocompleteSuggestions = suggestions
context.autocompleteRange = range
context.showAutocomplete = true
context.selectedAutocompleteIndex = 0
} else {
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.showAutocomplete = false
viewModel.selectedAutocompleteIndex = 0
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.showAutocomplete = false
context.selectedAutocompleteIndex = 0
}
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
guard let range = viewModel.autocompleteRange else { return text.count }
guard let range = context.autocompleteRange else { return text.count }
text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range)
text = context.applyAutocompleteSuggestion(nickname, to: text, range: range)
viewModel.showAutocomplete = false
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.selectedAutocompleteIndex = 0
context.showAutocomplete = false
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.selectedAutocompleteIndex = 0
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
}
@@ -52,10 +113,10 @@ final class ChatComposerCoordinator {
range: NSRange(location: 0, length: nsContent.length)
)
let peerNicknames = viewModel.meshService.getPeerNicknames()
let peerNicknames = context.meshPeerNicknames()
var validTokens = Set(peerNicknames.values)
validTokens.insert(viewModel.nickname)
validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4)))
validTokens.insert(context.nickname)
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
var mentions: [String] = []
for match in matches {
@@ -72,18 +133,18 @@ final class ChatComposerCoordinator {
private extension ChatComposerCoordinator {
func autocompleteCandidates() -> [String] {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
let values = viewModel.meshService.getPeerNicknames().values
return Array(values.filter { $0 != viewModel.meshService.myNickname })
let values = context.meshPeerNicknames().values
return Array(values.filter { $0 != context.meshNickname })
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in viewModel.geoNicknames {
for (pubkey, nick) in context.geoNicknames {
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
tokens.remove(myToken)
}
return Array(tokens)
@@ -2,34 +2,96 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatOutgoingCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatOutgoingContext: AnyObject {
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var isTeleported: Bool { get }
// MARK: Commands & private messages
func handleCommand(_ command: String)
func updatePrivateChatPeerIfNeeded()
func sendPrivateMessage(_ content: String, to peerID: PeerID)
// MARK: Public timeline (local echo)
func parseMentions(from content: String) -> [String]
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
func refreshVisibleMessages(from channel: ChannelID?)
func trimMessagesIfNeeded()
func addSystemMessage(_ content: String)
// MARK: Content dedup
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Outbound routing
/// Stamps "now" as the channel's last public activity (background nudges).
/// (Single mutation path for the owner's `lastPublicActivityAt`; this
/// coordinator never reads it.)
func recordPublicActivity(forChannelKey key: String)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
// MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatOutgoingContext {
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
// `trimMessagesIfNeeded()`, `addSystemMessage(_:)`,
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The single-writer intent op below lives next to
// its backing state's owner.
func recordPublicActivity(forChannelKey key: String) {
lastPublicActivityAt[key] = Date()
}
}
@MainActor
final class ChatOutgoingCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatOutgoingContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatOutgoingContext) {
self.context = context
}
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
if content.hasPrefix("/") {
Task { @MainActor [weak viewModel] in
viewModel?.handleCommand(content)
Task { @MainActor [weak context = self.context] in
context?.handleCommand(content)
}
return
}
if viewModel.selectedPrivateChatPeer != nil {
viewModel.updatePrivateChatPeerIfNeeded()
if context.selectedPrivateChatPeer != nil {
context.updatePrivateChatPeerIfNeeded()
if let selectedPeer = viewModel.selectedPrivateChatPeer {
viewModel.sendPrivateMessage(content, to: selectedPeer)
if let selectedPeer = context.selectedPrivateChatPeer {
context.sendPrivateMessage(content, to: selectedPeer)
}
return
}
let mentions = viewModel.parseMentions(from: content)
let mentions = context.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
@@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator {
mentions: [String]
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
var geoContext: ChatViewModel.GeoOutgoingContext?
var displaySender = viewModel.nickname
var localSenderPeerID = viewModel.meshService.myPeerID
var displaySender = context.nickname
var localSenderPeerID = context.myPeerID
var messageID: String?
var messageTimestamp = Date()
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
break
case .location(let channel):
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix
displaySender = context.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = viewModel.locationManager.teleported
let teleported = context.isTeleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
nickname: context.nickname,
teleported: teleported
)
@@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator {
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return nil
@@ -107,12 +169,12 @@ private extension ChatOutgoingCoordinator {
}
func appendLocalEcho(_ message: BitchatMessage) {
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
context.appendTimelineMessage(message, to: context.activeChannel)
context.refreshVisibleMessages(from: context.activeChannel)
let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp)
viewModel.trimMessagesIfNeeded()
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
context.trimMessagesIfNeeded()
}
func routePublicMessage(
@@ -122,10 +184,10 @@ private extension ChatOutgoingCoordinator {
messageID: String,
timestamp: Date
) {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
viewModel.lastPublicActivityAt["mesh"] = Date()
viewModel.meshService.sendMessage(
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: messageID,
@@ -133,18 +195,18 @@ private extension ChatOutgoingCoordinator {
)
case .location(let channel):
viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date()
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
Task { @MainActor [weak viewModel] in
viewModel?.sendGeohash(context: geoContext)
Task { @MainActor [weak context = self.context] in
context?.sendGeohash(context: geoContext)
}
}
}
@@ -2,16 +2,64 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatPeerListCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPeerListCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatPeerListContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
var unreadPrivateMessages: Set<PeerID> { get set }
var hasTrackedPrivateChatSelection: Bool { get }
func updatePrivateChatPeerIfNeeded()
func cleanupOldReadReceipts()
// MARK: Peers & sessions
var unifiedPeers: [BitchatPeer] { get }
func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool
/// Number of mesh peers currently connected or reachable, from the
/// transport's live peer snapshots.
func activeMeshPeerCount() -> Int
func registerEphemeralSession(peerID: PeerID)
func updateEncryptionStatusForPeers()
}
extension ChatViewModel: ChatPeerListContext {
// `isConnected`, `privateChats`, `unreadPrivateMessages`,
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
// `updateEncryptionStatusForPeers()` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// member below flattens the nested transport access into an intent-named
// call.
func activeMeshPeerCount() -> Int {
meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
.count
}
}
final class ChatPeerListCoordinator: @unchecked Sendable {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatPeerListContext
private var recentlySeenPeers: Set<PeerID> = []
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer?
private var networkEmptyTimer: Timer?
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatPeerListContext) {
self.context = context
}
deinit {
@@ -29,23 +77,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
private extension ChatPeerListCoordinator {
@MainActor
func handlePeerListUpdate(_ peers: [PeerID]) {
viewModel.isConnected = !peers.isEmpty
context.isConnected = !peers.isEmpty
cleanupStaleUnreadPeerIDs()
let meshPeers = peers.filter { peerID in
viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID)
context.isPeerConnected(peerID) || context.isPeerReachable(peerID)
}
handleNetworkAvailability(meshPeers)
for peerID in peers {
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
context.registerEphemeralSession(peerID: peerID)
}
viewModel.updateEncryptionStatusForPeers()
context.updateEncryptionStatusForPeers()
if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded()
if context.hasTrackedPrivateChatSelection {
context.updatePrivateChatPeerIfNeeded()
}
}
@@ -79,34 +127,34 @@ private extension ChatPeerListCoordinator {
@MainActor
func cleanupStaleUnreadPeerIDs() {
let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID))
let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs)
let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID))
let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs)
guard !staleIDs.isEmpty else {
viewModel.cleanupOldReadReceipts()
context.cleanupOldReadReceipts()
return
}
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty {
continue
}
if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
continue
}
idsToRemove.append(staleID)
viewModel.unreadPrivateMessages.remove(staleID)
context.unreadPrivateMessages.remove(staleID)
}
if !idsToRemove.isEmpty {
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
}
viewModel.cleanupOldReadReceipts()
context.cleanupOldReadReceipts()
}
@MainActor
@@ -121,18 +169,14 @@ private extension ChatPeerListCoordinator {
@MainActor
func handleNetworkResetTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
let activeMeshPeerCount = context.activeMeshPeerCount()
if activeMeshPeers.isEmpty {
if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else {
SecureLogger.debug(
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers",
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers",
category: .session
)
}
@@ -165,18 +209,14 @@ private extension ChatPeerListCoordinator {
@MainActor
func handleNetworkEmptyTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
let activeMeshPeerCount = context.activeMeshPeerCount()
if activeMeshPeers.isEmpty {
if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else {
SecureLogger.debug(
"⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again",
"⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again",
category: .session
)
}
+3 -3
View File
@@ -146,14 +146,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let unifiedPeerService: UnifiedPeerService
let autocompleteService: AutocompleteService
let deduplicationService: MessageDeduplicationService // internal for test access
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self)
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self)
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self)
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self)
private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self)
private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self)
private lazy var messageFormatter = ChatMessageFormatter(viewModel: self)
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self)
lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self)
lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self)
lazy var composerCoordinator = ChatComposerCoordinator(context: self)
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
@@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper {
func configureGeoChannels() {
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
locationManager: viewModel.locationManager,
onChannelSwitch: { [weak viewModel] channel in
viewModel?.switchLocationChannel(to: channel)
},
beginSampling: { [weak viewModel] geohashes in
viewModel?.beginGeohashSampling(for: geohashes)
},
endSampling: { [weak viewModel] in
viewModel?.endGeohashSampling()
}
context: viewModel
)
}
+27 -14
View File
@@ -9,15 +9,32 @@ import Combine
import Foundation
import Tor
/// The narrow surface `GeoChannelCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of capturing `ChatViewModel` in
/// per-callback closures. This keeps the coordinator independently testable
/// (see `GeoChannelCoordinatorContextTests`) and makes its true dependencies
/// explicit. Held `weak` the owner retains the coordinator, and every
/// callback was previously a `[weak viewModel]` capture.
@MainActor
protocol GeoChannelContext: AnyObject {
func switchLocationChannel(to channel: ChannelID)
func beginGeohashSampling(for geohashes: [String])
func endGeohashSampling()
}
// `switchLocationChannel(to:)`, `beginGeohashSampling(for:)`, and
// `endGeohashSampling()` are satisfied by existing `ChatViewModel` members.
extension ChatViewModel: GeoChannelContext {}
@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 weak var context: (any GeoChannelContext)?
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
@@ -27,16 +44,12 @@ final class GeoChannelCoordinator {
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
context: any GeoChannelContext
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
self.context = context
start()
}
@@ -50,7 +63,7 @@ final class GeoChannelCoordinator {
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
self.context?.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
@@ -84,7 +97,7 @@ final class GeoChannelCoordinator {
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
self.context?.switchLocationChannel(to: self.locationManager.selectedChannel)
}
updateSampling()
}
@@ -93,13 +106,13 @@ final class GeoChannelCoordinator {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
context?.endGeohashSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
context?.beginGeohashSampling(for: union)
} else {
endSampling()
context?.endGeohashSampling()
}
}
}