From bfb62d260662b45b82514a20bd69219ec7909870 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:42:47 +0000 Subject: [PATCH] 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 --- apps/ios/Shared/Model/DirectorySearch.swift | 86 ++++++++++ apps/ios/Shared/Model/SimpleXAPI.swift | 13 +- .../Shared/Views/ChatList/ChatListView.swift | 75 ++++++--- .../Views/ChatList/DirectorySearchView.swift | 151 +++++++++++++++--- 4 files changed, 279 insertions(+), 46 deletions(-) diff --git a/apps/ios/Shared/Model/DirectorySearch.swift b/apps/ios/Shared/Model/DirectorySearch.swift index 6b80090c15..a6df76ff97 100644 --- a/apps/ios/Shared/Model/DirectorySearch.swift +++ b/apps/ios/Shared/Model/DirectorySearch.swift @@ -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 } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 1410492da3..2c82349bc8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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) async -> ConnectionPlanResult? { +enum ConnectPlanFailure { + case alert, log +} + +func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue, 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 } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 961c2344c1..087eeefdfd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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) } } } } diff --git a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift index 5fb810240c..d033fff14f 100644 --- a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift +++ b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift @@ -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) -> OnlineSearchRows { + let nameChatIds = Set(names.compactMap { $0.localChatId }) + let nameLinks = Set(names.compactMap { $0.resolvedLink }) + func isAmong(_ ids: Set, _ 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(