mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 17:40:43 +00:00
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
187 lines
7.4 KiB
Swift
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 }
|
|
}
|