Don't suggest blocked people in @-mentions (#1543)

* Keep blocked peers out of @-mention suggestions

Blocked mesh nicknames and blocked geohash pubkeys no longer show up
in the composer autocomplete list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix blocked-mention test resetting private(set) state

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Taksh Kothari 2026-07-30 22:26:39 +05:30 committed by GitHub
parent e8f95e9a88
commit ab835e58c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 55 additions and 3 deletions

View File

@ -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<String>()
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) {

View File

@ -52,9 +52,19 @@ private final class MockChatComposerContext: ChatComposerContext {
var activeChannel: ChannelID = .mesh
var meshNickname = "me"
var meshNicknamesByPeerID: [PeerID: String] = [:]
var blockedMeshNicknames: Set<String> = []
var blockedNostrPubkeys: Set<String> = []
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()