diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift index c554a9e3..d0d347f2 100644 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -31,6 +31,10 @@ protocol ChatComposerContext: AnyObject { /// The transport's own nickname (excluded from autocomplete candidates). var meshNickname: String { get } func meshPeerNicknames() -> [PeerID: String] + /// True when this mesh nickname belongs to a blocked peer. + func isMeshNicknameBlocked(_ nickname: String) -> Bool + /// True when this geohash pubkey is blocked for location chats. + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool // MARK: Geohash identity (shared with the other contexts) var geoNicknames: [String: String] { get } @@ -40,8 +44,8 @@ protocol ChatComposerContext: AnyObject { extension ChatViewModel: ChatComposerContext { // `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`, // `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`, - // `geoNicknames`, `meshPeerNicknames()`, and - // `deriveNostrIdentity(forGeohash:)` are shared requirements with the + // `geoNicknames`, `meshPeerNicknames()`, `isNostrBlocked(pubkeyHexLowercased:)`, + // 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. @@ -60,6 +64,13 @@ extension ChatViewModel: ChatComposerContext { var meshNickname: String { meshService.myNickname } + + func isMeshNicknameBlocked(_ nickname: String) -> Bool { + for (peerID, nick) in meshService.getPeerNicknames() where nick == nickname { + if isPeerBlocked(peerID) { return true } + } + return false + } } @MainActor @@ -136,11 +147,14 @@ private extension ChatComposerCoordinator { switch context.activeChannel { case .mesh: let values = context.meshPeerNicknames().values - return Array(values.filter { $0 != context.meshNickname }) + return Array(values.filter { nick in + nick != context.meshNickname && !context.isMeshNicknameBlocked(nick) + }) case .location(let channel): var tokens = Set() for (pubkey, nick) in context.geoNicknames { + guard !context.isNostrBlocked(pubkeyHexLowercased: pubkey) else { continue } tokens.insert("\(nick)#\(pubkey.suffix(4))") } if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift index 24624897..d38ddc8a 100644 --- a/bitchatTests/ChatComposerCoordinatorContextTests.swift +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -52,9 +52,19 @@ private final class MockChatComposerContext: ChatComposerContext { var activeChannel: ChannelID = .mesh var meshNickname = "me" var meshNicknamesByPeerID: [PeerID: String] = [:] + var blockedMeshNicknames: Set = [] + var blockedNostrPubkeys: Set = [] func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID } + func isMeshNicknameBlocked(_ nickname: String) -> Bool { + blockedMeshNicknames.contains(nickname) + } + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) + } + // Geohash identity var geoNicknames: [String: String] = [:] static let dummyIdentity = NostrIdentity( @@ -120,6 +130,34 @@ struct ChatComposerCoordinatorContextTests { #expect(context.queriedPeerCandidates == [["carol#dddd"]]) } + @Test @MainActor + func updateAutocomplete_excludesBlockedMeshAndGeohashPeers() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [ + PeerID(str: "1111111111111111"): "alice", + PeerID(str: "2222222222222222"): "eve", + PeerID(str: "3333333333333333"): "me" + ] + context.blockedMeshNicknames = ["eve"] + context.queryResult = (["@alice"], NSRange(location: 0, length: 3)) + + coordinator.updateAutocomplete(for: "@a", cursorPosition: 2) + #expect(context.queriedPeerCandidates == [["alice"]]) + + let geoContext = MockChatComposerContext() + let geoCoordinator = ChatComposerCoordinator(context: geoContext) + geoContext.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq")) + geoContext.geoNicknames = [ + "aaaabbbbccccdddd": "carol", + "bbbbccccddddeeee": "blocked" + ] + geoContext.blockedNostrPubkeys = ["bbbbccccddddeeee"] + + geoCoordinator.updateAutocomplete(for: "@", cursorPosition: 1) + #expect(geoContext.queriedPeerCandidates == [["carol#dddd"]]) + } + @Test @MainActor func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() { let context = MockChatComposerContext()