diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index b05e0696e3..712c4cba2e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -166,6 +166,7 @@ struct ChatListView: View { @State private var userPickerShown: Bool = false @State private var sheet: SomeSheet? = 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 @Binding var parentSheet: SomeSheet? + @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 diff --git a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift new file mode 100644 index 0000000000..54df143044 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift @@ -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() + } + ) + ]} + ) +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4bd5f7db1b..15094577b1 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -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,