Files
simplex-chat/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift
T
spaced4ndy ac1fc2d6c1 review: round 1 fixes
R1-1 New chat clears its online results on a profile switch, as the chat list does
R1-3 the search spinner follows its two flags through property observers
R1-4 the join verb keeps one line, so the name truncates instead

Run: 002-ios-directory-search-ui, review round 1
2026-10-13 12:35:12 +00:00

335 lines
12 KiB
Swift

//
// DirectorySearchView.swift
// SimpleX
//
// Created by spaced4ndy on 13.08.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum DirectoryFooter {
case retry, showMore
}
// 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 names: [NameSearchResult] = []
@Published private(set) var directory: [DirectoryResult] = []
@Published private(set) var loading = false { didSet { updateSpinner() } }
@Published private(set) var resolvingNames = false { didSet { updateSpinner() } }
@Published private(set) var failed = false
// tells "nothing searched yet" apart from "searched and found nothing"
@Published private(set) var searched = false
private var cursor: JSONValue? = nil
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 searching: Bool { loading || resolvingNames }
var showResults: Bool { searching || searched || !names.isEmpty }
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .showMore : nil }
func reset() {
generation += 1
names = []
directory = []
cursor = nil
loading = false
resolvingNames = false
failed = false
searched = false
searchedText = ""
}
// a way out of the wait, not out of the results: what is already shown stays
func cancelRequest() {
generation += 1
loading = false
resolvingNames = false
}
func search(_ text: String, name: String?) async {
let text = text.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !(searching && text == searchedText) else { return }
reset()
searchedText = text
if let name {
resolvingNames = true
// 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)
}
func loadMore() async {
guard cursor != nil, !loading else { return }
await request(append: true)
}
// a failed "Show more" keeps its cursor, so the retry resumes the page rather than starting over
func retry() async {
if cursor != nil {
await loadMore()
} 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
names = results
}
private func request(append: Bool) async {
let gen = generation
loading = true
failed = false
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 }
loading = false
searched = true
guard let r else {
failed = true
return
}
cursor = r.cursor
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)
}
}
}
struct SearchInDirectoryRow: View {
@EnvironmentObject var theme: AppTheme
@FocusState.Binding var searchFocussed: Bool
var onSearch: () -> Void
var body: some View {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
.foregroundColor(theme.colors.primary)
Text("Search in Directory")
.foregroundColor(theme.colors.primary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
searchFocussed = false
onSearch()
}
}
}
struct DirectorySearchRow: View {
@EnvironmentObject var theme: AppTheme
var entry: DirectorySearchEntry
var body: some View {
HStack(spacing: 8) {
ProfileImage(
imageStr: entry.image,
iconName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill",
size: 42
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(displayName).fontWeight(.bold).lineLimit(1)
if let simplexName = entry.simplexName {
// the directory's claim, shown as plain text: connecting uses the link
Text(simplexName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
}
if let descr = entry.shortDescr, !descr.isEmpty {
Text(descr)
.foregroundColor(theme.colors.secondary)
.lineLimit(2)
}
Text(membersText)
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
Spacer()
Text(isChannel ? "Join channel" : "Join group")
.foregroundColor(theme.colors.primary)
.lineLimit(1)
.fixedSize()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private var displayName: String { "#" + entry.displayName }
private var isChannel: Bool { entry.entryType.groupType == .channel }
private var membersText: String {
let summary = entry.entryType.summary
let count = summary.publicMemberCount ?? summary.currentMembers
return isChannel
? subscriberCountStr(count)
: String.localizedStringWithFormat(NSLocalizedString("%d members", comment: "directory search result"), Int(count))
}
}
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
var body: some View {
Button {
Task { await onlineSearch.loadMore() }
} label: {
Text("Show more")
.foregroundColor(theme.colors.primary)
}
.disabled(onlineSearch.loading)
}
}
struct DirectoryRetryRow: View {
@EnvironmentObject var theme: AppTheme
var onlineSearch: OnlineSearchModel
var body: some View {
Button {
Task { await onlineSearch.retry() }
} label: {
Text("Search failed, tap to retry")
.foregroundColor(theme.colors.primary)
}
}
}
func confirmDirectorySearch(_ search: @escaping () -> Void) {
if UserDefaults.standard.bool(forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN) {
search()
} else {
showDirectorySearchAlert(onSearch: search)
}
}
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. 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(
title: NSLocalizedString("Search", comment: "alert action"),
style: .default,
handler: { _ in onSearch() }
),
UIAlertAction(
title: NSLocalizedString("Search and don't show again", comment: "alert action"),
style: .default,
handler: { _ in
UserDefaults.standard.set(true, forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN)
onSearch()
}
)
]}
)
}