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
This commit is contained in:
spaced4ndy
2026-10-13 11:42:47 +00:00
parent 8bfae1fb53
commit bfb62d2606
4 changed files with 279 additions and 46 deletions
@@ -98,3 +98,89 @@ func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults?
}
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 }
}
+11 -2
View File
@@ -1057,7 +1057,11 @@ func apiSearchDirectory(_ text: String, cursor: JSONValue?) async -> DirectorySe
return nil
}
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
enum ConnectPlanFailure {
case alert, log
}
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>, onFailure: ConnectPlanFailure = .alert) async -> ConnectionPlanResult? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return nil
@@ -1068,7 +1072,12 @@ func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, l
}
// a .never (typing) search that matches nothing locally is not an error to surface
if case .error(.error(.notResolvedLocally)) = r { return nil }
if let r { await apiConnectResponseAlert(r) }
if let r {
switch onFailure {
case .alert: await apiConnectResponseAlert(r)
case .log: logger.debug("apiConnectPlan: \(String(describing: r))")
}
}
return nil
}
@@ -447,9 +447,11 @@ struct ChatListView: View {
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.horizontal)
if onlineSearch.showResults {
let rows = onlineSearch.rows(shownChatIds: [])
List {
directoryRows()
if !onlineSearch.loading && onlineSearch.entries.isEmpty && onlineSearch.footer == nil {
nameRows(rows.names)
directoryRows(rows)
if !onlineSearch.searching && rows.isEmpty {
Text("No chats found")
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -478,26 +480,34 @@ struct ChatListView: View {
}
}
@ViewBuilder private func directoryRows() -> some View {
let footer = onlineSearch.footer
if !onlineSearch.entries.isEmpty || footer != nil {
Text("Directory")
.font(.callout)
.foregroundColor(theme.colors.secondary)
.accessibilityAddTraits(.isHeader)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
ForEach(onlineSearch.entries) { entry in
DirectorySearchRow(entry: entry)
@ViewBuilder private func nameRows(_ names: [NameSearchResult]) -> some View {
if !names.isEmpty {
searchPartHeader("Names")
ForEach(names) { result in
NameSearchRow(result: result)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(entry.link, theme: theme, dismiss: false, cleanup: nil)
planAndConnect(result.target, theme: theme, dismiss: false, cleanup: nil)
}
}
switch footer {
}
}
@ViewBuilder private func directoryRows(_ rows: OnlineSearchRows) -> some View {
if !rows.directory.isEmpty || rows.footer != nil {
searchPartHeader("Directory")
ForEach(rows.directory) { result in
DirectorySearchRow(entry: result.entry)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: nil)
}
}
switch rows.footer {
case .retry: directoryRetryRow()
case .showMore: directoryShowMoreRow()
case nil: EmptyView()
@@ -505,6 +515,16 @@ struct ChatListView: View {
}
}
private func searchPartHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.callout)
.foregroundColor(theme.colors.secondary)
.accessibilityAddTraits(.isHeader)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private func openGetStake() {
getStakeBannerTapped = true
showGetStakeSheet = true
@@ -512,6 +532,7 @@ struct ChatListView: View {
private var chatListContent: some View {
let cs = filteredChats()
let rows = onlineSearch.rows(shownChatIds: Set(cs.map { $0.id }))
return ZStack {
ScrollViewReader { scrollProxy in
List {
@@ -614,7 +635,8 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
directoryRows()
nameRows(rows.names)
directoryRows(rows)
if !addressCreationCardShown && hasConversations {
ConnectBannerCard()
.padding(.vertical, 6)
@@ -641,8 +663,8 @@ struct ChatListView: View {
}
}
}
// this covers the whole list, so it must not hide the directory section or its own messages
if cs.isEmpty && !chatModel.chats.isEmpty && !onlineSearch.loading && onlineSearch.entries.isEmpty && onlineSearch.footer == nil {
// this covers the whole list, so it must not hide the online results or their messages
if cs.isEmpty && !chatModel.chats.isEmpty && !onlineSearch.searching && rows.isEmpty {
noChatsView()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
@@ -840,7 +862,7 @@ struct ChatListSearchBar: View {
VStack(spacing: 12) {
// a typed name shows a row to connect to it (as on Android mobile): with the reachable toolbar it
// replaces the tags above the search field; in top bar mode the tags stay and it moves below (end of VStack)
if oneHandUI, let candidate = connectNameCandidate {
if oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
@@ -864,7 +886,7 @@ struct ChatListSearchBar: View {
.focused($searchFocussed)
.frame(maxWidth: .infinity)
.submitLabel(.search)
.onSubmit(runDirectorySearch)
.onSubmit(runOnlineSearch)
if connectProgressManager.showConnectProgress != nil {
ProgressView()
}
@@ -891,7 +913,7 @@ struct ChatListSearchBar: View {
toggleFilterButton()
}
}
if !oneHandUI, let candidate = connectNameCandidate {
if !oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
@@ -1015,7 +1037,14 @@ struct ChatListSearchBar: View {
private func runDirectorySearch() {
let text = searchTrimmed
guard !text.isEmpty, !searchShowingSimplexLink else { return }
confirmDirectorySearch { Task { await onlineSearch.search(text) } }
confirmDirectorySearch { Task { await onlineSearch.searchDirectory(text) } }
}
private func runOnlineSearch() {
let text = searchTrimmed
guard !text.isEmpty, !searchShowingSimplexLink else { return }
let name = connectNameCandidate
confirmDirectorySearch { Task { await onlineSearch.search(text, name: name) } }
}
}
@@ -13,11 +13,13 @@ enum DirectoryFooter {
case retry, showMore
}
// Directory results are not chats and are never saved: they live as long as the search text does.
// Online results are not chats and are never saved: they live as long as the search text does.
@MainActor
class OnlineSearchModel: ObservableObject {
@Published private(set) var entries: [DirectorySearchEntry] = []
@Published private(set) var names: [NameSearchResult] = []
@Published private(set) var directory: [DirectoryResult] = []
@Published private(set) var loading = false
@Published private(set) var resolvingNames = false
@Published private(set) var failed = false
// tells "nothing searched yet" apart from "searched and found nothing"
@Published private(set) var searched = false
@@ -26,32 +28,63 @@ class OnlineSearchModel: ObservableObject {
private var searchedText = ""
// bumped on every reset, so a reply that arrives too late cannot refill a list already cleared
private var generation = 0
private var spinnerShown = false
var showResults: Bool { loading || searched }
var searching: Bool { loading || resolvingNames }
var showResults: Bool { searching || searched || !names.isEmpty }
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .showMore : nil }
func reset() {
generation += 1
entries = []
names = []
directory = []
cursor = nil
loading = false
resolvingNames = false
failed = false
searched = false
searchedText = ""
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
updateSpinner()
}
// a way out of the wait, not out of the results: the page already shown stays
// a way out of the wait, not out of the results: what is already shown stays
func cancelRequest() {
generation += 1
loading = false
resolvingNames = false
updateSpinner()
}
func search(_ text: String) async {
func search(_ text: String, name: String?) async {
let text = text.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !(loading && text == searchedText) else { return }
guard !text.isEmpty, !(searching && text == searchedText) else { return }
reset()
searchedText = text
if let name {
resolvingNames = true
updateSpinner()
// captured here: the async let initializer runs later, in the child task, possibly after a reset
let gen = generation
async let resolution: Void = resolveNames(name, generation: gen)
await request(append: false)
await resolution
} else {
await request(append: false)
}
}
func searchDirectory(_ text: String) async {
let text = text.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !(loading && text == searchedText) else { return }
if text == searchedText {
directory = []
cursor = nil
failed = false
searched = false
} else {
reset()
searchedText = text
}
await request(append: false)
}
@@ -64,34 +97,83 @@ class OnlineSearchModel: ObservableObject {
func retry() async {
if cursor != nil {
await loadMore()
} else {
await search(searchedText)
} else if !loading {
await request(append: false)
}
}
func rows(shownChatIds: Set<ChatId>) -> OnlineSearchRows {
let nameChatIds = Set(names.compactMap { $0.localChatId })
let nameLinks = Set(names.compactMap { $0.resolvedLink })
func isAmong(_ ids: Set<ChatId>, _ id: ChatId?) -> Bool {
id.map { ids.contains($0) } ?? false
}
return OnlineSearchRows(
names: names.filter { !isAmong(shownChatIds, $0.localChatId) },
directory: directory.filter {
!isAmong(shownChatIds, $0.localChatId) && !isAmong(nameChatIds, $0.localChatId) && !nameLinks.contains($0.entry.link)
},
footer: footer
)
}
private func resolveNames(_ name: String, generation gen: Int) async {
let targets = name.hasPrefix("@") || name.hasPrefix("#") ? [name] : ["@" + name, "#" + name]
var results: [NameSearchResult] = []
for target in targets {
// checked before each target: a cancelled or replaced search must not send more names to the server
guard gen == generation else { return }
if let r = await apiConnectPlan(connLink: target, resolveMode: .unknown, inProgress: BoxedValue(false), onFailure: .log),
let result = NameSearchResult(target: target, result: r) {
results.append(result)
}
}
guard gen == generation else { return }
resolvingNames = false
updateSpinner()
names = results
}
private func request(append: Bool) async {
let gen = generation
loading = true
failed = false
ConnectProgressManager.shared.startConnectProgress(
NSLocalizedString("Searching directory…", comment: "in progress text"),
owner: .directorySearch
) { [weak self] in
Task { @MainActor in self?.cancelRequest() }
}
updateSpinner()
let r = await apiSearchDirectory(searchedText, cursor: cursor)
var page: [DirectoryResult] = []
if let r {
var seen = Set(directory.map { $0.id })
for entry in r.entries {
guard seen.insert(entry.id).inserted else { continue }
let plan = await apiConnectPlan(connLink: entry.link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log)
page.append(DirectoryResult(entry: entry, localChatId: plan.flatMap { planLocalChatId($0.connectionPlan) }))
}
}
guard gen == generation else { return }
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
loading = false
searched = true
updateSpinner()
guard let r else {
failed = true
return
}
cursor = r.cursor
let known = Set(entries.map { $0.id })
let fresh = r.entries.filter { !known.contains($0.id) }
entries = append ? entries + fresh : fresh
directory = append ? directory + page : page
}
private func updateSpinner() {
guard searching != spinnerShown else { return }
spinnerShown = searching
if spinnerShown {
ConnectProgressManager.shared.startConnectProgress(
NSLocalizedString("Searching directory…", comment: "in progress text"),
owner: .directorySearch
) { [weak self] in
Task { @MainActor in self?.cancelRequest() }
}
} else {
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
}
}
}
@@ -168,6 +250,33 @@ struct DirectorySearchRow: View {
}
}
struct NameSearchRow: View {
@EnvironmentObject var theme: AppTheme
var result: NameSearchResult
var body: some View {
HStack(spacing: 8) {
ProfileImage(imageStr: result.image, iconName: result.iconName, size: 42)
VStack(alignment: .leading, spacing: 2) {
if let profileName = result.profileName {
(Text(profileName).fontWeight(.bold) + Text(verbatim: " (\(result.simplexName))").foregroundColor(theme.colors.secondary))
.lineLimit(1)
} else {
Text(result.simplexName).fontWeight(.bold).lineLimit(1)
}
if let descr = result.descr, !descr.isEmpty {
Text(descr)
.foregroundColor(theme.colors.secondary)
.lineLimit(2)
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
struct DirectoryShowMoreRow: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var onlineSearch: OnlineSearchModel
@@ -208,7 +317,7 @@ func confirmDirectorySearch(_ search: @escaping () -> Void) {
private func showDirectorySearchAlert(onSearch: @escaping () -> Void) {
showAlert(
NSLocalizedString("Search in Directory?", comment: "alert title"),
message: NSLocalizedString("The text in the search field will be sent to SimpleX Directory to find public groups and channels.\n\nNo contact is created and your profile is not sent.", comment: "alert message"),
message: NSLocalizedString("The text in the search field will be sent to SimpleX Directory to find public groups and channels. If it is a SimpleX name, it will also be sent to your server that resolves names.\n\nNo contact is created and your profile is not sent.", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .cancel),
UIAlertAction(