mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-14 04:59:56 +00:00
wip
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user