This commit is contained in:
spaced4ndy
2026-08-13 21:30:59 +04:00
parent 9170a3041f
commit d5922caee6
3 changed files with 325 additions and 20 deletions
+151 -20
View File
@@ -166,6 +166,7 @@ struct ChatListView: View {
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
@StateObject private var directorySearch = DirectorySearchModel()
@State private var scrollToItemId: ChatItem.ID? = nil
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
@@ -382,35 +383,75 @@ struct ChatListView: View {
@ViewBuilder private var chatList: some View {
if shouldShowOnboarding {
ConnectOnboardingView()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.modifier(ThemedBackground())
// the onboarding content stays, but below a live search bar rather than instead of
// it: a user with no conversations is exactly who needs to find some
VStack(spacing: 0) {
ChatListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
parentSheet: $sheet,
directorySearch: directorySearch
)
.padding(.horizontal)
if directorySearch.entries.isEmpty && !directorySearch.loading {
ConnectOnboardingView()
} else {
directorySearchList()
}
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.modifier(ThemedBackground())
} else {
chatListContent
}
}
// the directory results on their own, for the onboarding state where there is no chat list
@ViewBuilder private func directorySearchList() -> some View {
List {
ForEach(directorySearch.entries) { entry in
DirectorySearchRow(entry: entry)
.listRowBackground(Color.clear)
.onTapGesture {
guard let link = entry.connectLink else { return }
searchFocussed = false
planAndConnect(link, theme: theme, dismiss: false, cleanup: nil)
}
}
if directorySearch.failed {
directoryRetryRow()
} else if directorySearch.hasMore {
directoryShowMoreRow()
}
}
.listStyle(.plain)
}
private var chatListContent: some View {
let cs = filteredChats()
return ZStack {
ScrollViewReader { scrollProxy in
List {
if !chatModel.chats.isEmpty {
ChatListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
parentSheet: $sheet
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.frame(maxWidth: .infinity)
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
// always shown: the search field is now the way to discover chats, not only
// to filter the ones that already exist
ChatListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
parentSheet: $sheet,
directorySearch: directorySearch
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.frame(maxWidth: .infinity)
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
if !oneHandUICardShown {
OneHandUICard()
.padding(.vertical, 6)
@@ -437,6 +478,28 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
if !directorySearch.entries.isEmpty || directorySearch.loading || directorySearch.failed {
Section {
ForEach(directorySearch.entries) { entry in
DirectorySearchRow(entry: entry)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
guard let link = entry.connectLink else { return }
searchFocussed = false
planAndConnect(link, theme: theme, dismiss: false, cleanup: nil)
}
}
if directorySearch.failed {
directoryRetryRow()
} else if directorySearch.hasMore {
directoryShowMoreRow()
}
} header: {
Text("Directory")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
}
}
if !addressCreationCardShown && hasConversations {
ConnectBannerCard()
.padding(.vertical, 6)
@@ -463,13 +526,37 @@ struct ChatListView: View {
}
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
// the overlay covers the list, so it must not appear while directory results are shown
if cs.isEmpty && !chatModel.chats.isEmpty && directorySearch.entries.isEmpty && !directorySearch.loading {
noChatsView()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
@ViewBuilder private func directoryShowMoreRow() -> some View {
Button {
Task { await directorySearch.loadMore() }
} label: {
Text("Show more")
.foregroundColor(theme.colors.primary)
}
.disabled(directorySearch.loading)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
}
@ViewBuilder private func directoryRetryRow() -> some View {
Button {
Task { await directorySearch.search(searchText) }
} label: {
Text("Search failed, tap to retry")
.foregroundColor(theme.colors.primary)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
}
@ViewBuilder private func noChatsView() -> some View {
if searchString().isEmpty {
@@ -638,7 +725,9 @@ struct ChatListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: Set<String>
@Binding var parentSheet: SomeSheet<AnyView>?
@ObservedObject var directorySearch: DirectorySearchModel
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN) private var directorySearchAlertShown = false
@State private var ignoreSearchTextChange = false
// when the search text is a SimpleX name, the string to connect to (with @/# preserved); nil otherwise
@State private var connectNameCandidate: String? = nil
@@ -656,6 +745,9 @@ struct ChatListSearchBar: View {
searchFocussed: $searchFocussed,
dismiss: false
)
searchInDirectoryRow()
} else if oneHandUI, !searchTrimmed.isEmpty {
searchInDirectoryRow()
} else {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
}
@@ -667,6 +759,8 @@ struct ChatListSearchBar: View {
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
.submitLabel(.search)
.onSubmit(runDirectorySearch)
if connectProgressManager.showConnectProgress != nil {
ProgressView()
}
@@ -702,10 +796,20 @@ struct ChatListSearchBar: View {
dismiss: false
)
}
if !oneHandUI, !searchTrimmed.isEmpty {
searchInDirectoryRow()
}
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
}
.onChange(of: searchText) { _ in
// results belong to the text that produced them
directorySearch.reset()
}
.onChange(of: m.currentUser?.userId) { _ in
directorySearch.reset()
}
.onChange(of: searchText) { t in
if ignoreSearchTextChange {
ignoreSearchTextChange = false
@@ -795,6 +899,33 @@ struct ChatListSearchBar: View {
filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] }
)
}
private var searchTrimmed: String {
searchText.trimmingCharacters(in: .whitespaces)
}
@ViewBuilder private func searchInDirectoryRow() -> some View {
// a SimpleX link connects on its own, so the directory is not offered for one
if !searchShowingSimplexLink {
SearchInDirectoryRow(
searchText: searchTrimmed,
searchFocussed: $searchFocussed,
onSearch: runDirectorySearch
)
}
}
// The search text leaves the device, so the first time it does the user is asked first.
private func runDirectorySearch() {
let text = searchTrimmed
guard !text.isEmpty, !searchShowingSimplexLink else { return }
let search = { Task { await directorySearch.search(text) } }
if directorySearchAlertShown {
search()
} else {
showDirectorySearchAlert(onSearch: search)
}
}
}
// Row shown when the search text is a SimpleX name in place of the list tags in the chat list, below
@@ -0,0 +1,171 @@
//
// DirectorySearchView.swift
// SimpleX
//
// Created by spaced4ndy on 13.08.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// Results of searching the directory over the service RPC. They are not chats and are never
// persisted: they live as long as the search text does.
@MainActor
class DirectorySearchModel: ObservableObject {
@Published private(set) var entries: [DirectorySearchEntry] = []
@Published private(set) var loading = false
@Published private(set) var failed = false
// set once a search has actually run, so the empty state can tell "not searched yet" 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 after the text, profile or host changed
// cannot repopulate a list the user has moved on from
private var generation = 0
var hasMore: Bool { cursor != nil }
func reset() {
generation += 1
entries = []
cursor = nil
loading = false
failed = false
searched = false
searchedText = ""
}
func search(_ text: String) async {
let text = text.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { return }
reset()
searchedText = text
await request(append: false)
}
func loadMore() async {
guard cursor != nil, !loading else { return }
await request(append: true)
}
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?.reset() }
}
let r = await apiSearchDirectory(searchedText, cursor: cursor)
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
guard gen == generation else { return }
loading = false
searched = true
guard let r else {
failed = true
return
}
cursor = r.cursor
// the link is the identity of a result, so a row cannot appear twice across pages
let known = Set(entries.map { $0.id })
let fresh = r.entries.filter { !known.contains($0.id) }
entries = append ? entries + fresh : fresh
}
}
// Offered whenever there is search text, next to the connect-by-name row. Tapping it sends the
// text to the directory.
struct SearchInDirectoryRow: View {
@EnvironmentObject var theme: AppTheme
var searchText: String
@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, 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()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private var displayName: String { "#" + entry.displayName }
private var membersText: String {
String.localizedStringWithFormat(
NSLocalizedString("%d members", comment: "directory search result"),
Int(entry.entryType.summary.currentMembers)
)
}
}
// Shown before the first directory search of the session: the search text leaves the device,
// so the user is told before it does, not after.
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"),
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()
}
)
]}
)
}
@@ -56,6 +56,7 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN = "directorySearchAlertShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
@@ -117,6 +118,7 @@ let appDefaults: [String: Any] = [
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
@@ -148,6 +150,7 @@ let hintDefaults = [
DEFAULT_LA_NOTICE_SHOWN,
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SIGN_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,