mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 19:42:13 +00:00
The row was disabled while loading but kept its primary colour, so it looked tappable. The Kotlin app already does this.
335 lines
12 KiB
Swift
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(onlineSearch.loading ? theme.colors.secondary : 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()
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
}
|