mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +00:00
Extract BLE packet handlers and migrate coordinators to narrow contexts
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,22 +4,208 @@ import Foundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
|
||||
final class ChatNostrCoordinator {
|
||||
private unowned let viewModel: ChatViewModel
|
||||
/// The narrow surface `ChatNostrCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
/// minimal context it actually uses instead of holding a back-reference to the
|
||||
/// whole `ChatViewModel`. This keeps the coordinator independently testable
|
||||
/// (see `ChatNostrCoordinatorContextTests`) and makes its true dependencies
|
||||
/// explicit. The surface is intentionally large — it documents the
|
||||
/// coordinator's real coupling to channel/subscription state, the inbound
|
||||
/// Nostr event pipeline, geohash presence, and the ack transports.
|
||||
@MainActor
|
||||
protocol ChatNostrContext: AnyObject {
|
||||
// MARK: Channel & subscription state
|
||||
var activeChannel: ChannelID { get set }
|
||||
var currentGeohash: String? { get set }
|
||||
var geoSubscriptionID: String? { get set }
|
||||
var geoDmSubscriptionID: String? { get set }
|
||||
/// Geohash sampling subscriptions: subscription ID -> geohash.
|
||||
var geoSamplingSubs: [String: String] { get set }
|
||||
/// Per-geohash notification cooldown: geohash -> last notify time.
|
||||
var lastGeoNotificationAt: [String: Date] { get set }
|
||||
var nostrRelayManager: NostrRelayManager? { get }
|
||||
|
||||
init(viewModel: ChatViewModel) {
|
||||
self.viewModel = viewModel
|
||||
// MARK: Public timeline & pipeline
|
||||
var messages: [BitchatMessage] { get }
|
||||
func resetPublicMessagePipeline()
|
||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID)
|
||||
func refreshVisibleMessages(from channel: ChannelID?)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
func drainPendingGeohashSystemMessages() -> [String]
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||
func synchronizePublicConversationStore(forGeohash geohash: String)
|
||||
|
||||
// MARK: Inbound public messages
|
||||
func handlePublicMessage(_ message: BitchatMessage)
|
||||
func checkForMentions(_ message: BitchatMessage)
|
||||
func sendHapticFeedback(for message: BitchatMessage)
|
||||
func parseMentions(from content: String) -> [String]
|
||||
|
||||
// MARK: Inbound private (geohash DM) payloads
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var nostrKeyMapping: [PeerID: String] { get set }
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
)
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
|
||||
// MARK: Nostr identity & blocking (shared with `ChatPrivateConversationContext`)
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||
func currentNostrIdentity() -> NostrIdentity?
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
||||
|
||||
// MARK: Event dedup
|
||||
func hasProcessedNostrEvent(_ eventID: String) -> Bool
|
||||
func recordProcessedNostrEvent(_ eventID: String)
|
||||
func clearProcessedNostrEvents()
|
||||
|
||||
// MARK: Geo participants & presence
|
||||
var geoNicknames: [String: String] { get }
|
||||
var teleportedGeoCount: Int { get }
|
||||
func startGeoParticipantRefreshTimer()
|
||||
func stopGeoParticipantRefreshTimer()
|
||||
func setActiveParticipantGeohash(_ geohash: String?)
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String)
|
||||
func geoParticipantCount(for geohash: String) -> Int
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
func clearTeleportedGeo()
|
||||
func clearGeoNicknames()
|
||||
func visibleGeohashPeople() -> [GeoPerson]
|
||||
|
||||
// MARK: Location channels
|
||||
var isTeleported: Bool { get }
|
||||
/// True when regional channels are known and the geohash is not one of them.
|
||||
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool
|
||||
|
||||
// MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`)
|
||||
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatNostrContext {
|
||||
// `activeChannel`, `selectedPrivateChatPeer`, `nostrKeyMapping`,
|
||||
// `messages`, `geoNicknames`, the Nostr identity/blocking members, and the
|
||||
// routing/ack members are shared requirements with `ChatDeliveryContext` /
|
||||
// `ChatPrivateConversationContext`; their witnesses already exist. The
|
||||
// members below flatten nested service accesses into intent-named calls.
|
||||
|
||||
func resetPublicMessagePipeline() {
|
||||
publicMessagePipeline.reset()
|
||||
}
|
||||
|
||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {
|
||||
publicMessagePipeline.updateActiveChannel(channel)
|
||||
}
|
||||
|
||||
func drainPendingGeohashSystemMessages() -> [String] {
|
||||
timelineStore.drainPendingGeohashSystemMessages()
|
||||
}
|
||||
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||
timelineStore.appendIfAbsent(message, toGeohash: geohash)
|
||||
}
|
||||
|
||||
func hasProcessedNostrEvent(_ eventID: String) -> Bool {
|
||||
deduplicationService.hasProcessedNostrEvent(eventID)
|
||||
}
|
||||
|
||||
func recordProcessedNostrEvent(_ eventID: String) {
|
||||
deduplicationService.recordNostrEvent(eventID)
|
||||
}
|
||||
|
||||
func clearProcessedNostrEvents() {
|
||||
deduplicationService.clearNostrCaches()
|
||||
}
|
||||
|
||||
var teleportedGeoCount: Int {
|
||||
locationPresenceStore.teleportedGeo.count
|
||||
}
|
||||
|
||||
func startGeoParticipantRefreshTimer() {
|
||||
participantTracker.startRefreshTimer()
|
||||
}
|
||||
|
||||
func stopGeoParticipantRefreshTimer() {
|
||||
participantTracker.stopRefreshTimer()
|
||||
}
|
||||
|
||||
func setActiveParticipantGeohash(_ geohash: String?) {
|
||||
participantTracker.setActiveGeohash(geohash)
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash)
|
||||
}
|
||||
|
||||
func geoParticipantCount(for geohash: String) -> Int {
|
||||
participantTracker.participantCount(for: geohash)
|
||||
}
|
||||
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
|
||||
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
|
||||
}
|
||||
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||
locationPresenceStore.markTeleported(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||
locationPresenceStore.clearTeleported(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
locationPresenceStore.clearTeleportedGeo()
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
locationPresenceStore.clearGeoNicknames()
|
||||
}
|
||||
|
||||
var isTeleported: Bool {
|
||||
locationManager.teleported
|
||||
}
|
||||
|
||||
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
|
||||
let channels = locationManager.availableChannels
|
||||
return !channels.isEmpty && !channels.contains { $0.geohash == geohash }
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatNostrCoordinator {
|
||||
private weak var context: (any ChatNostrContext)?
|
||||
private var recentGeoSamplingEventIDs = Set<String>()
|
||||
private var recentGeoSamplingEventIDOrder: [String] = []
|
||||
|
||||
init(context: any ChatNostrContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resubscribeCurrentGeohash() {
|
||||
guard case .location(let channel) = viewModel.activeChannel else { return }
|
||||
guard let subID = viewModel.geoSubscriptionID else {
|
||||
switchLocationChannel(to: viewModel.activeChannel)
|
||||
guard let context else { return }
|
||||
guard case .location(let channel) = context.activeChannel else { return }
|
||||
guard let subID = context.geoSubscriptionID else {
|
||||
switchLocationChannel(to: context.activeChannel)
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.participantTracker.startRefreshTimer()
|
||||
context.startGeoParticipantRefreshTimer()
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
channel.geohash,
|
||||
@@ -36,14 +222,14 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
if let dmSub = viewModel.geoDmSubscriptionID {
|
||||
if let dmSub = context.geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
viewModel.geoDmSubscriptionID = nil
|
||||
context.geoDmSubscriptionID = nil
|
||||
}
|
||||
|
||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
let dmSub = "geo-dm-\(channel.geohash)"
|
||||
viewModel.geoDmSubscriptionID = dmSub
|
||||
context.geoDmSubscriptionID = dmSub
|
||||
let dmFilter = NostrFilter.giftWrapsFor(
|
||||
pubkey: identity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
@@ -58,18 +244,19 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!viewModel.deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
!context.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
if let gh = viewModel.currentGeohash,
|
||||
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
|
||||
if let gh = context.currentGeohash,
|
||||
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
@@ -79,12 +266,12 @@ final class ChatNostrCoordinator {
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmed
|
||||
viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey)
|
||||
context.setGeoNickname(nick, forPubkey: event.pubkey)
|
||||
}
|
||||
|
||||
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
@@ -97,24 +284,24 @@ final class ChatNostrCoordinator {
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
let isSelf: Bool = {
|
||||
if let gh = viewModel.currentGeohash,
|
||||
let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
|
||||
if let gh = context.currentGeohash,
|
||||
let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
|
||||
return myIdentity.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.locationPresenceStore.markTeleported(key)
|
||||
Task { @MainActor [weak context] in
|
||||
context?.markGeoTeleported(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
|
||||
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmed
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = viewModel.parseMentions(from: content)
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -125,22 +312,23 @@ final class ChatNostrCoordinator {
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
viewModel.handlePublicMessage(message)
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
context.handlePublicMessage(message)
|
||||
if !isBlocked {
|
||||
viewModel.checkForMentions(message)
|
||||
viewModel.sendHapticFeedback(for: message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
@@ -155,11 +343,11 @@ final class ChatNostrCoordinator {
|
||||
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
viewModel.nostrKeyMapping[convKey] = senderPubkey
|
||||
context.nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
viewModel.handlePrivateMessage(
|
||||
context.handlePrivateMessage(
|
||||
noisePayload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
@@ -167,9 +355,9 @@ final class ChatNostrCoordinator {
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
@@ -177,67 +365,66 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func switchLocationChannel(to channel: ChannelID) {
|
||||
viewModel.publicMessagePipeline.reset()
|
||||
viewModel.activeChannel = channel
|
||||
viewModel.publicMessagePipeline.updateActiveChannel(channel)
|
||||
guard let context else { return }
|
||||
context.resetPublicMessagePipeline()
|
||||
context.activeChannel = channel
|
||||
context.updatePublicMessagePipelineChannel(channel)
|
||||
|
||||
viewModel.deduplicationService.clearNostrCaches()
|
||||
context.clearProcessedNostrEvents()
|
||||
switch channel {
|
||||
case .mesh:
|
||||
viewModel.refreshVisibleMessages(from: .mesh)
|
||||
let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
context.refreshVisibleMessages(from: .mesh)
|
||||
let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
viewModel.participantTracker.stopRefreshTimer()
|
||||
viewModel.participantTracker.setActiveGeohash(nil)
|
||||
viewModel.locationPresenceStore.clearTeleportedGeo()
|
||||
context.stopGeoParticipantRefreshTimer()
|
||||
context.setActiveParticipantGeohash(nil)
|
||||
context.clearTeleportedGeo()
|
||||
|
||||
case .location:
|
||||
viewModel.refreshVisibleMessages(from: channel)
|
||||
context.refreshVisibleMessages(from: channel)
|
||||
}
|
||||
|
||||
if case .location = channel {
|
||||
for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() {
|
||||
viewModel.addPublicSystemMessage(content)
|
||||
for content in context.drainPendingGeohashSystemMessages() {
|
||||
context.addPublicSystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
if let sub = viewModel.geoSubscriptionID {
|
||||
if let sub = context.geoSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
viewModel.geoSubscriptionID = nil
|
||||
context.geoSubscriptionID = nil
|
||||
}
|
||||
if let dmSub = viewModel.geoDmSubscriptionID {
|
||||
if let dmSub = context.geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
viewModel.geoDmSubscriptionID = nil
|
||||
context.geoDmSubscriptionID = nil
|
||||
}
|
||||
viewModel.currentGeohash = nil
|
||||
viewModel.participantTracker.setActiveGeohash(nil)
|
||||
viewModel.locationPresenceStore.clearGeoNicknames()
|
||||
context.currentGeohash = nil
|
||||
context.setActiveParticipantGeohash(nil)
|
||||
context.clearGeoNicknames()
|
||||
|
||||
guard case .location(let channel) = channel else { return }
|
||||
viewModel.currentGeohash = channel.geohash
|
||||
viewModel.participantTracker.setActiveGeohash(channel.geohash)
|
||||
context.currentGeohash = channel.geohash
|
||||
context.setActiveParticipantGeohash(channel.geohash)
|
||||
|
||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
||||
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
if viewModel.locationManager.teleported && hasRegional && !inRegional {
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||
context.markGeoTeleported(key)
|
||||
SecureLogger.info(
|
||||
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
||||
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
viewModel.locationPresenceStore.clearTeleported(key)
|
||||
context.clearGeoTeleported(key)
|
||||
}
|
||||
}
|
||||
|
||||
let subID = "geo-\(channel.geohash)"
|
||||
viewModel.geoSubscriptionID = subID
|
||||
viewModel.participantTracker.startRefreshTimer()
|
||||
context.geoSubscriptionID = subID
|
||||
context.startGeoParticipantRefreshTimer()
|
||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||
@@ -252,6 +439,7 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
@@ -259,13 +447,13 @@ final class ChatNostrCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
||||
if context.hasProcessedNostrEvent(event.id) { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
|
||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -274,8 +462,8 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let isSelf: Bool = {
|
||||
if let gh = viewModel.currentGeohash,
|
||||
let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
|
||||
if let gh = context.currentGeohash,
|
||||
let my = try? context.deriveNostrIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||
}
|
||||
return false
|
||||
@@ -283,17 +471,17 @@ final class ChatNostrCoordinator {
|
||||
|
||||
if hasTeleportTag, !isSelf {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.markGeoTeleported(key)
|
||||
SecureLogger.info(
|
||||
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
||||
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
@@ -303,17 +491,17 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey)
|
||||
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
|
||||
}
|
||||
|
||||
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
|
||||
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||
@@ -324,7 +512,7 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = viewModel.parseMentions(from: content)
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -335,20 +523,21 @@ final class ChatNostrCoordinator {
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
viewModel.handlePublicMessage(message)
|
||||
viewModel.checkForMentions(message)
|
||||
viewModel.sendHapticFeedback(for: message)
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.handlePublicMessage(message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeToGeoChat(_ channel: GeohashChannel) {
|
||||
guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return }
|
||||
guard let context else { return }
|
||||
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
|
||||
|
||||
let dmSub = "geo-dm-\(channel.geohash)"
|
||||
viewModel.geoDmSubscriptionID = dmSub
|
||||
context.geoDmSubscriptionID = dmSub
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
}
|
||||
@@ -365,11 +554,12 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
@@ -392,12 +582,12 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
viewModel.nostrKeyMapping[convKey] = senderPubkey
|
||||
context.nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
viewModel.handlePrivateMessage(
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
@@ -405,19 +595,20 @@ final class ChatNostrCoordinator {
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
|
||||
let channel = context.channel
|
||||
let event = context.event
|
||||
let identity = context.identity
|
||||
func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) {
|
||||
guard let context else { return }
|
||||
let channel = geoContext.channel
|
||||
let event = geoContext.event
|
||||
let identity = geoContext.identity
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: channel.geohash,
|
||||
@@ -430,42 +621,41 @@ final class ChatNostrCoordinator {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
SecureLogger.debug(
|
||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)",
|
||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
|
||||
category: .session
|
||||
)
|
||||
|
||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
||||
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
|
||||
if context.teleported && hasRegional && !inRegional {
|
||||
if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
context.markGeoTeleported(key)
|
||||
SecureLogger.info(
|
||||
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
||||
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
guard let context else { return }
|
||||
if !TorManager.shared.isForeground() {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
|
||||
let desired = Set(geohashes)
|
||||
let current = Set(viewModel.geoSamplingSubs.values)
|
||||
let current = Set(context.geoSamplingSubs.values)
|
||||
let toAdd = desired.subtracting(current)
|
||||
let toRemove = current.subtracting(desired)
|
||||
|
||||
for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) {
|
||||
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
viewModel.geoSamplingSubs.removeValue(forKey: subID)
|
||||
context.geoSamplingSubs.removeValue(forKey: subID)
|
||||
}
|
||||
|
||||
for gh in toAdd {
|
||||
@@ -475,8 +665,9 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func subscribe(_ gh: String) {
|
||||
guard let context else { return }
|
||||
let subID = "geo-sample-\(gh)"
|
||||
viewModel.geoSamplingSubs[subID] = gh
|
||||
context.geoSamplingSubs[subID] = gh
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
gh,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||
@@ -492,19 +683,21 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||
|
||||
let existingCount = viewModel.participantTracker.participantCount(for: gh)
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
let existingCount = context.geoParticipantCount(for: gh)
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
if let my = try? context.deriveNostrIdentity(forGeohash: gh),
|
||||
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
return
|
||||
}
|
||||
@@ -515,10 +708,10 @@ final class ChatNostrCoordinator {
|
||||
|
||||
#if os(iOS)
|
||||
guard UIApplication.shared.applicationState == .active else { return }
|
||||
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
|
||||
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
|
||||
#elseif os(macOS)
|
||||
guard NSApplication.shared.isActive else { return }
|
||||
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
|
||||
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
|
||||
#endif
|
||||
|
||||
cooldownPerGeohash(gh, content: content, event: event)
|
||||
@@ -526,8 +719,9 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
let now = Date()
|
||||
let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast
|
||||
let last = context.lastGeoNotificationAt[gh] ?? .distantPast
|
||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
||||
|
||||
let preview: String = {
|
||||
@@ -537,16 +731,16 @@ final class ChatNostrCoordinator {
|
||||
return String(content[..<idx]) + "…"
|
||||
}()
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
viewModel.lastGeoNotificationAt[gh] = now
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.lastGeoNotificationAt[gh] = now
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = viewModel.geoNicknames[event.pubkey.lowercased()]
|
||||
let nick = context.geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let ts = min(rawTs, Date())
|
||||
let mentions = viewModel.parseMentions(from: content)
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -556,8 +750,8 @@ final class ChatNostrCoordinator {
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if viewModel.timelineStore.appendIfAbsent(message, toGeohash: gh) {
|
||||
viewModel.synchronizePublicConversationStore(forGeohash: gh)
|
||||
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
|
||||
context.synchronizePublicConversationStore(forGeohash: gh)
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
@@ -565,15 +759,41 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func endGeohashSampling() {
|
||||
for subID in viewModel.geoSamplingSubs.keys {
|
||||
guard let context else { return }
|
||||
for subID in context.geoSamplingSubs.keys {
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
}
|
||||
viewModel.geoSamplingSubs.removeAll()
|
||||
context.geoSamplingSubs.removeAll()
|
||||
clearGeoSamplingEventDedup()
|
||||
}
|
||||
|
||||
private func shouldProcessGeoSamplingEvent(_ eventID: String) -> Bool {
|
||||
guard !eventID.isEmpty else { return true }
|
||||
guard recentGeoSamplingEventIDs.insert(eventID).inserted else {
|
||||
return false
|
||||
}
|
||||
recentGeoSamplingEventIDOrder.append(eventID)
|
||||
|
||||
let cap = TransportConfig.geoSamplingEventLRUCap
|
||||
if recentGeoSamplingEventIDOrder.count > cap {
|
||||
let removeCount = recentGeoSamplingEventIDOrder.count - cap
|
||||
for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) {
|
||||
recentGeoSamplingEventIDs.remove(staleID)
|
||||
}
|
||||
recentGeoSamplingEventIDOrder.removeFirst(removeCount)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func clearGeoSamplingEventDedup() {
|
||||
recentGeoSamplingEventIDs.removeAll()
|
||||
recentGeoSamplingEventIDOrder.removeAll()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setupNostrMessageHandling() {
|
||||
guard let currentIdentity = try? viewModel.idBridge.getCurrentNostrIdentity() else {
|
||||
guard let context else { return }
|
||||
guard let currentIdentity = context.currentNostrIdentity() else {
|
||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -588,7 +808,7 @@ final class ChatNostrCoordinator {
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
)
|
||||
|
||||
viewModel.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
@@ -597,8 +817,10 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
@@ -607,8 +829,9 @@ final class ChatNostrCoordinator {
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||
try? viewModel.idBridge.getCurrentNostrIdentity()
|
||||
context.currentNostrIdentity()
|
||||
}
|
||||
guard let currentIdentity else { return }
|
||||
|
||||
@@ -640,11 +863,11 @@ final class ChatNostrCoordinator {
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
await MainActor.run {
|
||||
viewModel.nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
context.nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
viewModel.handlePrivateMessage(
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: targetPeerID,
|
||||
@@ -652,9 +875,9 @@ final class ChatNostrCoordinator {
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
@@ -711,33 +934,26 @@ final class ChatNostrCoordinator {
|
||||
senderPubkey: String,
|
||||
key: Data?
|
||||
) {
|
||||
guard let context else { return }
|
||||
if let _ = key {
|
||||
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
} else if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
SecureLogger.debug(
|
||||
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
} else if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
SecureLogger.debug(
|
||||
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||
category: .session
|
||||
@@ -798,6 +1014,7 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
guard let context else { return }
|
||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
||||
relationship.peerNostrPublicKey != nil else {
|
||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
||||
@@ -805,15 +1022,16 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let peerID = PeerID(hexData: noisePublicKey)
|
||||
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
for person in viewModel.visibleGeohashPeople() where person.displayName == name {
|
||||
guard let context else { return nil }
|
||||
for person in context.visibleGeohashPeople() where person.displayName == name {
|
||||
return person.id
|
||||
}
|
||||
for (pub, nick) in viewModel.geoNicknames where nick == name {
|
||||
for (pub, nick) in context.geoNicknames where nick == name {
|
||||
return pub
|
||||
}
|
||||
return nil
|
||||
@@ -821,22 +1039,25 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
guard let context else { return }
|
||||
let convKey = PeerID(nostr_: hex)
|
||||
viewModel.nostrKeyMapping[convKey] = hex
|
||||
viewModel.startPrivateChat(with: convKey)
|
||||
context.nostrKeyMapping[convKey] = hex
|
||||
context.startPrivateChat(with: convKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
||||
viewModel.nostrKeyMapping[senderID]
|
||||
guard let context else { return nil }
|
||||
return context.nostrKeyMapping[senderID]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
||||
guard let full = viewModel.nostrKeyMapping[convKey] else {
|
||||
guard let context else { return convKey.bare }
|
||||
guard let full = context.nostrKeyMapping[convKey] else {
|
||||
return convKey.bare
|
||||
}
|
||||
return viewModel.displayNameForNostrPubkey(full)
|
||||
return context.displayNameForNostrPubkey(full)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user