Files
simplex-chat/apps/ios/Shared/Model/DirectorySearch.swift
spaced4ndy bfb62d2606 ios: resolve SimpleX names on search, show each result once
The keyboard Search key on the chat list now also resolves a typed
SimpleX name online, alongside the directory search, and shows what it
finds as a Names part between the local chats and the directory
results. Resolution never raises an alert: apiConnectPlan gains an
onFailure mode, and existing callers keep alerting by default.

Each directory entry is looked up in the store (store-only, as entries
are now group or channel links only) so a chat the user already sees
locally, or found by name, is not shown again; names already in the
local list are hidden the same way. A directory row is matched to a
name only by the link the core resolved, never by a profile field the
name's owner writes, so a name cannot hide a genuine directory row.

The "Search in Directory" row, retry and "Show more" keep the names
already shown. One helper drives the spinner from whether anything is
in flight, and every publish checks the search generation, so a late
reply after a text change or cancel writes nothing. The first-search
alert now also names the server that resolves names.

Run: 002-ios-directory-search-ui, slice 3
2026-10-13 11:42:47 +00:00

187 lines
7.4 KiB
Swift

//
// DirectorySearch.swift
// SimpleX
//
// Created by spaced4ndy on 12.08.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
// The directory's address, as published in docs/DIRECTORY.md. It must be the short link form -
// only that one carries the keys a service request needs; a full link fails every time.
let DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
// A search sets up an encrypted connection, so it takes seconds, not milliseconds.
let DIRECTORY_SEARCH_TIMEOUT_SEC: Double = 10
struct DirectoryPublicLink: Decodable, Hashable {
var connFullLink: String? = nil
var connShortLink: String? = nil
}
struct DirectoryEntryType: Decodable, Hashable {
var groupType: GroupType? = nil
var summary: GroupSummary
}
struct DirectorySearchEntry: Decodable, Hashable, Identifiable {
var entryType: DirectoryEntryType
var displayName: String
var simplexName: String? = nil
let link: String
var shortDescr: String? = nil
var image: String? = nil
var activeAt: Date? = nil
var createdAt: Date? = nil
// the link is stable and unique per entry, so it also de-duplicates across pages
var id: String { link }
enum CodingKeys: String, CodingKey {
case entryType, displayName, simplexName, groupLink, shortDescr, image, activeAt, createdAt
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
entryType = try c.decode(DirectoryEntryType.self, forKey: .entryType)
displayName = try c.decode(String.self, forKey: .displayName)
simplexName = try c.decodeIfPresent(String.self, forKey: .simplexName)
shortDescr = try c.decodeIfPresent(String.self, forKey: .shortDescr)
image = try c.decodeIfPresent(String.self, forKey: .image)
activeAt = try c.decodeIfPresent(Date.self, forKey: .activeAt)
createdAt = try c.decodeIfPresent(Date.self, forKey: .createdAt)
let groupLink = try c.decode(DirectoryPublicLink.self, forKey: .groupLink)
// untrusted input: only group and channel links are kept, as the core fetches an invitation link even in a store-only plan
guard let link = groupLink.connShortLink ?? groupLink.connFullLink,
let linkType = simplexLinkType(link),
linkType == .group || linkType == .channel else {
throw DecodingError.dataCorruptedError(forKey: .groupLink, in: c, debugDescription: "not a group or channel link")
}
self.link = link
}
}
func simplexLinkType(_ s: String) -> SimplexLinkType? {
if case let .link(text, linkType, _) = strConnectTarget(s), text == s { linkType } else { nil }
}
// entries stay undecoded here so they can be decoded one at a time: one entry the app does not
// understand, because a newer directory added a field, must not discard the whole response
private struct DirectorySearchResponse: Decodable {
var type: String
var entries: [JSONValue]?
var searchCursor: JSONValue?
}
struct DirectorySearchResults {
var entries: [DirectorySearchEntry]
// opaque: stored and echoed back on the next request, never inspected
var cursor: JSONValue?
}
func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String {
var req: [String: JSONValue] = ["type": .string("search"), "searchText": .string(text)]
if let cursor { req["searchCursor"] = cursor }
return encodeJSON(JSONValue.object(req))
}
// Anything but a well-formed results response is a failure, not something to salvage: it comes
// from another party, not from our own core.
func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults? {
guard let r: DirectorySearchResponse = decodeJSONValue(resp), r.type == "searchResults" else {
return nil
}
let entries = (r.entries ?? []).compactMap { (e: JSONValue) -> DirectorySearchEntry? in
decodeJSONValue(e)
}
return DirectorySearchResults(entries: entries, cursor: r.searchCursor)
}
func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? {
switch plan {
case let .contactAddress(contactAddressPlan):
switch contactAddressPlan {
case let .known(contact), let .connectingProhibit(contact), let .contactViaAddress(contact): return contact.id
case .ok, .ownLink, .connectingConfirmReconnect: return nil
}
case let .groupLink(groupLinkPlan):
switch groupLinkPlan {
case let .known(groupInfo), let .ownLink(groupInfo): return groupInfo.id
case let .connectingProhibit(groupInfo_): return groupInfo_?.id
case .ok, .connectingConfirmReconnect, .noRelays, .updateRequired: return nil
}
case let .invitationLink(invitationLinkPlan):
switch invitationLinkPlan {
case let .known(contact): return contact.id
case let .connecting(contact_): return contact_?.id
case .ok, .ownLink: return nil
}
case .error: return nil
}
}
struct NameSearchResult: Identifiable {
let target: String
let simplexName: String
let profileName: String?
let descr: String?
let image: String?
let iconName: String
let localChatId: ChatId?
// the link the core resolved the name to, never a profile field the name's owner wrote
let resolvedLink: String?
var id: String { target }
init?(target: String, result: ConnectionPlanResult) {
let profile: (any NamedChat)?
switch result.connectionPlan {
case let .contactAddress(contactAddressPlan):
switch contactAddressPlan {
case let .ok(contactSLinkData_, _): profile = contactSLinkData_?.profile
case let .contactViaAddress(contact), let .connectingProhibit(contact), let .known(contact): profile = contact.profile
case .connectingConfirmReconnect: profile = nil
case .ownLink: return nil
}
iconName = "person.crop.circle.fill"
resolvedLink = nil
case let .groupLink(groupLinkPlan):
let groupProfile: GroupProfile?
switch groupLinkPlan {
case let .ok(_, groupSLinkData_, _), let .noRelays(groupSLinkData_), let .updateRequired(groupSLinkData_): groupProfile = groupSLinkData_?.groupProfile
case let .known(groupInfo), let .ownLink(groupInfo): groupProfile = groupInfo.groupProfile
case let .connectingProhibit(groupInfo_): groupProfile = groupInfo_?.groupProfile
case .connectingConfirmReconnect: groupProfile = nil
}
profile = groupProfile
iconName = groupProfile?.isChannel == true ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"
resolvedLink = result.connLink.connShortLink
case .invitationLink, .error:
return nil
}
self.target = target
simplexName = result.planSimplexName?.shortStr ?? target
profileName = profile?.displayName
descr = profile?.shortDescr
image = profile?.image
localChatId = planLocalChatId(result.connectionPlan)
}
}
struct DirectoryResult: Identifiable {
let entry: DirectorySearchEntry
let localChatId: ChatId?
var id: String { entry.id }
}
struct OnlineSearchRows {
var names: [NameSearchResult]
var directory: [DirectoryResult]
var footer: DirectoryFooter?
var isEmpty: Bool { names.isEmpty && directory.isEmpty && footer == nil }
}