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(