mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 00:45:21 +00:00
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>
214 lines
8.0 KiB
Swift
214 lines
8.0 KiB
Swift
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 context: any ChatOutgoingContext
|
|
|
|
init(context: any ChatOutgoingContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func sendMessage(_ content: String) {
|
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
|
|
|
if content.hasPrefix("/") {
|
|
Task { @MainActor [weak context = self.context] in
|
|
context?.handleCommand(content)
|
|
}
|
|
return
|
|
}
|
|
|
|
if context.selectedPrivateChatPeer != nil {
|
|
context.updatePrivateChatPeerIfNeeded()
|
|
|
|
if let selectedPeer = context.selectedPrivateChatPeer {
|
|
context.sendPrivateMessage(content, to: selectedPeer)
|
|
}
|
|
return
|
|
}
|
|
|
|
let mentions = context.parseMentions(from: content)
|
|
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
|
guard let preparedMessage else { return }
|
|
|
|
appendLocalEcho(preparedMessage.message)
|
|
routePublicMessage(
|
|
originalContent: content,
|
|
mentions: mentions,
|
|
geoContext: preparedMessage.geoContext,
|
|
messageID: preparedMessage.message.id,
|
|
timestamp: preparedMessage.message.timestamp
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension ChatOutgoingCoordinator {
|
|
func preparePublicMessage(
|
|
content: String,
|
|
trimmed: String,
|
|
mentions: [String]
|
|
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
|
var geoContext: ChatViewModel.GeoOutgoingContext?
|
|
var displaySender = context.nickname
|
|
var localSenderPeerID = context.myPeerID
|
|
var messageID: String?
|
|
var messageTimestamp = Date()
|
|
|
|
switch context.activeChannel {
|
|
case .mesh:
|
|
break
|
|
|
|
case .location(let channel):
|
|
do {
|
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
|
displaySender = context.nickname + "#" + suffix
|
|
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
|
|
|
let teleported = context.isTeleported
|
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
|
content: trimmed,
|
|
geohash: channel.geohash,
|
|
senderIdentity: identity,
|
|
nickname: context.nickname,
|
|
teleported: teleported
|
|
)
|
|
|
|
messageID = event.id
|
|
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
geoContext = (
|
|
channel: channel,
|
|
event: event,
|
|
identity: identity,
|
|
teleported: teleported
|
|
)
|
|
} catch {
|
|
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
|
context.addSystemMessage(
|
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: displaySender,
|
|
content: trimmed,
|
|
timestamp: messageTimestamp,
|
|
isRelay: false,
|
|
senderPeerID: localSenderPeerID,
|
|
mentions: mentions.isEmpty ? nil : mentions
|
|
)
|
|
|
|
return (message, geoContext)
|
|
}
|
|
|
|
func appendLocalEcho(_ message: BitchatMessage) {
|
|
context.appendTimelineMessage(message, to: context.activeChannel)
|
|
context.refreshVisibleMessages(from: context.activeChannel)
|
|
|
|
let contentKey = context.normalizedContentKey(message.content)
|
|
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
|
context.trimMessagesIfNeeded()
|
|
}
|
|
|
|
func routePublicMessage(
|
|
originalContent: String,
|
|
mentions: [String],
|
|
geoContext: ChatViewModel.GeoOutgoingContext?,
|
|
messageID: String,
|
|
timestamp: Date
|
|
) {
|
|
switch context.activeChannel {
|
|
case .mesh:
|
|
context.recordPublicActivity(forChannelKey: "mesh")
|
|
context.sendMeshMessage(
|
|
originalContent,
|
|
mentions: mentions,
|
|
messageID: messageID,
|
|
timestamp: timestamp
|
|
)
|
|
|
|
case .location(let channel):
|
|
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)
|
|
context.addSystemMessage(
|
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
|
)
|
|
return
|
|
}
|
|
|
|
Task { @MainActor [weak context = self.context] in
|
|
context?.sendGeohash(context: geoContext)
|
|
}
|
|
}
|
|
}
|
|
}
|