From c92d3a41139f31522cdbd9c3eb5cc046222d2b49 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:46:58 +0000 Subject: [PATCH] ios: online search shows one list of directory results with Open, and asks to enable Directory search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search key and "Search in Directory" search the directory only; a SimpleX name is looked up with "Connect to …", which now stays next to the results. The first search asks to enable Directory search, a new switch in Privacy settings. Results form one list without headers: your chats first, then the others at the chat row's icon size with Open, which opens the prepared chat without the "Open channel?" alert, or "Already joining" for a pending join. The load row says how many results are left; a long press shows Open. The prepared channel's banner shows its subscriber count. Run: 011-search-redesign, slice 3 --- apps/ios/Shared/Model/DirectorySearch.swift | 69 ++---- apps/ios/Shared/Views/Chat/ChatView.swift | 8 + .../Shared/Views/ChatList/ChatListView.swift | 105 +++----- .../Views/ChatList/DirectorySearchView.swift | 226 ++++++++---------- .../Views/NewChat/NewChatMenuButton.swift | 58 ++--- .../Shared/Views/NewChat/NewChatView.swift | 43 +++- .../Views/UserSettings/PrivacySettings.swift | 4 + .../Views/UserSettings/SettingsView.swift | 5 +- 8 files changed, 216 insertions(+), 302 deletions(-) diff --git a/apps/ios/Shared/Model/DirectorySearch.swift b/apps/ios/Shared/Model/DirectorySearch.swift index b274a3e102..c5389f86ac 100644 --- a/apps/ios/Shared/Model/DirectorySearch.swift +++ b/apps/ios/Shared/Model/DirectorySearch.swift @@ -73,12 +73,14 @@ private struct DirectorySearchResponse: Decodable { var type: String var entries: [JSONValue]? var searchCursor: JSONValue? + var moreCount: JSONValue? } struct DirectorySearchResults { var entries: [DirectorySearchEntry] // opaque: stored and echoed back on the next request, never inspected var cursor: JSONValue? + var moreCount: Int? } func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String { @@ -96,7 +98,9 @@ func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults? let entries = (r.entries ?? []).compactMap { (e: JSONValue) -> DirectorySearchEntry? in decodeJSONValue(e) } - return DirectorySearchResults(entries: entries, cursor: r.searchCursor) + // only shown as text, so a count out of range is dropped rather than failing the response + let moreCount: Int? = if case let .number(n)? = r.moreCount, n >= 0, n <= Double(Int32.max), n == n.rounded() { Int(n) } else { nil } + return DirectorySearchResults(entries: entries, cursor: r.searchCursor, moreCount: moreCount) } func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? { @@ -122,66 +126,33 @@ func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? { } } -struct NameSearchResult: Identifiable { - let target: String - let simplexName: String - let profileName: String? - let descr: String? - let image: String? - let iconName: String - var localChatId: ChatId? - // the link the core resolved the name to, never a profile field the name's owner wrote - let resolvedLink: String? +enum DirectoryResultState { + case yourChat(ChatId) + case joining + case open +} - var id: String { target } - - init?(target: String, result: ConnectionPlanResult) { - let profile: (any NamedChat)? - switch result.connectionPlan { - case let .contactAddress(contactAddressPlan): - switch contactAddressPlan { - case let .ok(contactSLinkData_, _): profile = contactSLinkData_?.profile - case let .contactViaAddress(contact), let .connectingProhibit(contact), let .known(contact): profile = contact.profile - case .connectingConfirmReconnect: profile = nil - case .ownLink: return nil - } - iconName = "person.crop.circle.fill" - resolvedLink = nil - case let .groupLink(groupLinkPlan): - let groupProfile: GroupProfile? - switch groupLinkPlan { - case let .ok(_, groupSLinkData_, _), let .noRelays(groupSLinkData_), let .updateRequired(groupSLinkData_): groupProfile = groupSLinkData_?.groupProfile - case let .known(groupInfo), let .ownLink(groupInfo): groupProfile = groupInfo.groupProfile - case let .connectingProhibit(groupInfo_): groupProfile = groupInfo_?.groupProfile - case .connectingConfirmReconnect: groupProfile = nil - } - profile = groupProfile - iconName = groupProfile?.isChannel == true ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill" - resolvedLink = result.connLink.connShortLink - case .invitationLink, .error: - return nil - } - self.target = target - simplexName = result.planSimplexName?.shortStr ?? target - profileName = profile?.displayName - descr = profile?.shortDescr - image = profile?.image - localChatId = planLocalChatId(result.connectionPlan) +// no plan means the store doesn't know the link, or the plan failed and Open's online plan will show why +func directoryResultState(_ plan: ConnectionPlan?) -> DirectoryResultState { + if let chatId = plan.flatMap(planLocalChatId) { return .yourChat(chatId) } + switch plan { + case .groupLink(.connectingConfirmReconnect)?, .groupLink(.connectingProhibit(nil))?: return .joining + default: return .open } } struct DirectoryResult: Identifiable { let entry: DirectorySearchEntry - var localChatId: ChatId? + var state: DirectoryResultState var id: String { entry.id } } struct OnlineSearchRows { - var names: [NameSearchResult] - var directory: [DirectoryResult] + var yourChats: [ChatId] + var results: [DirectoryResult] var pageInChats: Bool var footer: DirectoryFooter? - var isEmpty: Bool { names.isEmpty && directory.isEmpty && !pageInChats && footer == nil } + var isEmpty: Bool { yourChats.isEmpty && results.isEmpty && !pageInChats && footer == nil } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2a903fcd91..18cc6af1e9 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1027,6 +1027,14 @@ struct ChatView: View { .frame(maxWidth: 260) } + if case let .group(groupInfo, _) = chat.chatInfo, + groupInfo.nextConnectPrepared && groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount { + Text(subscriberCountStr(count)) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + } + ProfileDescriptionView(shortDescr: chat.chatInfo.shortDescr, description: chat.chatInfo.profileDescription) .padding(.horizontal) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index fe5f745ace..5c2b51f156 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -170,6 +170,7 @@ struct ChatListView: View { @StateObject private var chatTagsModel = ChatTagsModel.shared @StateObject private var onlineSearch = OnlineSearchModel() @State private var scrollToItemId: ChatItem.ID? = nil + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize // iOS 15 is required it to show/hide toolbar while chat is hidden/visible @State private var viewOnScreen = true @@ -449,7 +450,6 @@ struct ChatListView: View { if onlineSearch.showResults { let rows = onlineSearch.rows(shownChatIds: []) List { - nameRows(rows.names) directoryRows(rows) if !onlineSearch.searching && rows.isEmpty { Text("No chats found") @@ -480,67 +480,30 @@ struct ChatListView: View { } } - @ViewBuilder private func nameRows(_ names: [NameSearchResult]) -> some View { - if !names.isEmpty { - searchPart("Names") { - ForEach(names) { result in - NameSearchRow(result: result) - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .listRowBackground(Color.clear) - .onTapGesture { - searchFocussed = false - planAndConnect(result.target, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } }) - } - } - } - } - } - @ViewBuilder private func directoryRows(_ rows: OnlineSearchRows) -> some View { - if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil { - searchPart("Directory") { - ForEach(rows.directory) { result in - if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) { - chatListNavLink(chat) - } else { - DirectorySearchRow(entry: result.entry) - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .listRowBackground(Color.clear) - .onTapGesture { - searchFocussed = false - planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } }) - } - } - } - if rows.pageInChats { directoryPageInChatsRow() } - switch rows.footer { - case .retry: directoryRetryRow() - case .showMore: directoryShowMoreRow() - case nil: EmptyView() - } - } + ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in + chatListNavLink(chat) + } + let imageSize = dynamicSize(userFont).profileImageSize + ForEach(rows.results) { result in + // the menu's preview is drawn upright only when the menu is attached before the row is flipped + DirectorySearchRow(entry: result.entry, imageSize: imageSize, state: result.state) + .modifier(DirectoryResultMenu(result: result, imageSize: imageSize, onOpen: { openResult(result) })) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowBackground(Color.clear) + .onTapGesture { openResult(result) } + } + if rows.pageInChats { directoryPageInChatsRow() } + switch rows.footer { + case .retry: directoryRetryRow() + case .loadMore: directoryLoadMoreRow() + case nil: EmptyView() } } - // the list is flipped in one-hand UI, so there the header follows its rows to be drawn above them - @ViewBuilder private func searchPart(_ title: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View { - if oneHandUI { - content() - searchPartHeader(title) - } else { - searchPartHeader(title) - content() - } - } - - private func searchPartHeader(_ title: LocalizedStringKey) -> some View { - Text(title) - .font(.callout) - .foregroundColor(theme.colors.secondary) - .accessibilityAddTraits(.isHeader) - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) + private func openResult(_ result: DirectoryResult) { + searchFocussed = false + openDirectoryResult(result, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshStates() } }) } private func openGetStake() { @@ -636,7 +599,6 @@ struct ChatListView: View { .onAppear { chatModel.chatListBanner = .getStake } } ForEach(cs, id: \.viewId) { chat in chatListNavLink(chat) } - nameRows(rows.names) directoryRows(rows) if showsCards && !addressCreationCardShown && hasConversations { ConnectBannerCard() @@ -697,8 +659,8 @@ struct ChatListView: View { .listRowBackground(Color.clear) } - @ViewBuilder private func directoryShowMoreRow() -> some View { - DirectoryShowMoreRow(onlineSearch: onlineSearch) + @ViewBuilder private func directoryLoadMoreRow() -> some View { + DirectoryLoadMoreRow(onlineSearch: onlineSearch) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowBackground(Color.clear) } @@ -887,7 +849,7 @@ struct ChatListSearchBar: View { VStack(spacing: 12) { // a typed name shows a row to connect to it (as on Android mobile): with the reachable toolbar it // replaces the tags above the search field; in top bar mode the tags stay and it moves below (end of VStack) - if oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty { + if oneHandUI, let candidate = connectNameCandidate { ConnectByNameRow( name: candidate, searchText: $searchText, @@ -895,10 +857,10 @@ struct ChatListSearchBar: View { searchFocussed: $searchFocussed, dismiss: false ) - searchInDirectoryRow() + if !onlineSearch.showResults { searchInDirectoryRow() } // a SimpleX link connects on its own, so the directory is not offered for one } else if oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink { - searchInDirectoryRow() + if !onlineSearch.showResults { searchInDirectoryRow() } } else { ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } } @@ -911,7 +873,7 @@ struct ChatListSearchBar: View { .focused($searchFocussed) .frame(maxWidth: .infinity) .submitLabel(.search) - .onSubmit(runOnlineSearch) + .onSubmit(runDirectorySearch) if connectProgressManager.showConnectProgress != nil { ProgressView() } @@ -938,7 +900,7 @@ struct ChatListSearchBar: View { toggleFilterButton() } } - if !oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty { + if !oneHandUI, let candidate = connectNameCandidate { ConnectByNameRow( name: candidate, searchText: $searchText, @@ -947,7 +909,7 @@ struct ChatListSearchBar: View { dismiss: false ) } - if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink { + if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink, !onlineSearch.showResults { searchInDirectoryRow() } } @@ -1058,19 +1020,12 @@ struct ChatListSearchBar: View { SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch) } - // whatever is typed here gets sent to the directory, so ask before the first time + // whatever is typed here gets sent to the directory, so ask until Directory search is enabled private func runDirectorySearch() { let text = searchTrimmed guard !text.isEmpty, !searchShowingSimplexLink else { return } confirmDirectorySearch { Task { await onlineSearch.searchDirectory(text) } } } - - private func runOnlineSearch() { - let text = searchTrimmed - guard !text.isEmpty, !searchShowingSimplexLink else { return } - let name = connectNameCandidate - confirmDirectorySearch { Task { await onlineSearch.search(text, name: name) } } - } } // 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 index d17888690c..d8ba6f3812 100644 --- a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift +++ b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift @@ -10,16 +10,15 @@ import SwiftUI import SimpleXChat enum DirectoryFooter { - case retry, showMore + case retry, loadMore } // 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 moreCount: Int? = nil @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 @@ -31,18 +30,17 @@ class OnlineSearchModel: ObservableObject { 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 } + var searching: Bool { loading } + var showResults: Bool { loading || searched } + var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .loadMore : nil } func reset() { generation += 1 - names = [] directory = [] + moreCount = nil cursor = nil lastPageIds = [] loading = false - resolvingNames = false failed = false searched = false searchedText = "" @@ -52,24 +50,6 @@ class OnlineSearchModel: ObservableObject { 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 { @@ -77,6 +57,7 @@ class OnlineSearchModel: ObservableObject { guard !text.isEmpty, !(loading && text == searchedText) else { return } if text == searchedText { directory = [] + moreCount = nil cursor = nil lastPageIds = [] failed = false @@ -93,7 +74,7 @@ class OnlineSearchModel: ObservableObject { await request(append: true) } - // a failed "Show more" keeps its cursor, so the retry resumes the page rather than starting over + // a failed "Load more" keeps its cursor, so the retry resumes the page rather than starting over func retry() async { if cursor != nil { await loadMore() @@ -103,68 +84,50 @@ class OnlineSearchModel: ObservableObject { } func rows(shownChatIds: Set) -> OnlineSearchRows { - let nameChatIds = Set(names.compactMap { $0.localChatId }) - let nameLinks = Set(names.compactMap { $0.resolvedLink }) - func isAmong(_ ids: Set, _ id: ChatId?) -> Bool { - id.map { ids.contains($0) } ?? false + var yourChats: [ChatId] = [] + var results: [DirectoryResult] = [] + for result in directory { + switch result.state { + case let .yourChat(chatId) where ChatModel.shared.getChat(chatId) != nil: + if !shownChatIds.contains(chatId) && !yourChats.contains(chatId) { + yourChats.append(chatId) + } + case .yourChat: + results.append(DirectoryResult(entry: result.entry, state: .open)) + case .joining, .open: + results.append(result) + } } let lastPage = directory.filter { lastPageIds.contains($0.id) } 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) - }, - // a result hidden only because a name resolved to its link is on screen, but not one of your chats - pageInChats: !lastPage.isEmpty && lastPage.allSatisfy { - isAmong(shownChatIds, $0.localChatId) || isAmong(nameChatIds, $0.localChatId) + yourChats: yourChats, + results: results, + pageInChats: !lastPage.isEmpty && lastPage.allSatisfy { result in + if case let .yourChat(chatId) = result.state { shownChatIds.contains(chatId) } else { false } }, footer: footer ) } - // a chat opened from a result exists only once its connection flow ends, so rows without a chat look again then - func refreshLocalChatIds() async { + // a chat opened from a result exists only once its connection flow ends, so results look again then + func refreshStates() async { let gen = generation - var nameChatIds: [String: ChatId] = [:] - for result in names where result.localChatId == nil { - nameChatIds[result.id] = await localChatId(result.target) - } - var directoryChatIds: [String: ChatId] = [:] - for result in directory where result.localChatId == nil { - directoryChatIds[result.id] = await localChatId(result.entry.link) + var states: [String: DirectoryResultState] = [:] + for result in directory { + if case .yourChat = result.state { continue } + states[result.id] = await resultState(result.entry.link) } guard gen == generation else { return } - names = names.map { result in - var r = result - r.localChatId = r.localChatId ?? nameChatIds[r.id] - return r - } directory = directory.map { result in var r = result - r.localChatId = r.localChatId ?? directoryChatIds[r.id] + r.state = states[r.id] ?? r.state return r } } - private func localChatId(_ link: String) async -> ChatId? { + private func resultState(_ link: String) async -> DirectoryResultState { let plan = await apiConnectPlan(connLink: link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log) - return plan.flatMap { planLocalChatId($0.connectionPlan) } - } - - 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 + return directoryResultState(plan?.connectionPlan) } private func request(append: Bool) async { @@ -177,7 +140,7 @@ class OnlineSearchModel: ObservableObject { var seen = Set(directory.map { $0.id }) for entry in r.entries { guard seen.insert(entry.id).inserted else { continue } - page.append(DirectoryResult(entry: entry, localChatId: await localChatId(entry.link))) + page.append(DirectoryResult(entry: entry, state: await resultState(entry.link))) } } guard gen == generation else { return } @@ -188,6 +151,7 @@ class OnlineSearchModel: ObservableObject { return } cursor = r.cursor + moreCount = r.moreCount directory = append ? directory + page : page lastPageIds = Set(page.map { $0.id }) } @@ -233,13 +197,16 @@ struct SearchInDirectoryRow: View { struct DirectorySearchRow: View { @EnvironmentObject var theme: AppTheme var entry: DirectorySearchEntry + var imageSize: CGFloat + var state: DirectoryResultState + var showsOpen = true var body: some View { HStack(spacing: 8) { ProfileImage( imageStr: entry.image, icon: Image(systemName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"), - size: 42 + size: imageSize ) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { @@ -256,15 +223,17 @@ struct DirectorySearchRow: View { .foregroundColor(theme.colors.secondary) .lineLimit(2) } - Text(membersText) + Text(joining ? NSLocalizedString("Already joining", comment: "directory search result") : membersText) .font(.caption) .foregroundColor(theme.colors.secondary) } Spacer() - Text(isChannel ? "Join channel" : "Join group") - .foregroundColor(theme.colors.primary) - .lineLimit(1) - .fixedSize() + if showsOpen && !joining { + Text("Open") + .foregroundColor(theme.colors.primary) + .lineLimit(1) + .fixedSize() + } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -274,6 +243,10 @@ struct DirectorySearchRow: View { private var isChannel: Bool { entry.entryType.groupType == .channel } + private var joining: Bool { + if case .joining = state { true } else { false } + } + private var membersText: String { let summary = entry.entryType.summary let count = summary.publicMemberCount ?? summary.currentMembers @@ -283,33 +256,6 @@ struct DirectorySearchRow: View { } } -struct NameSearchRow: View { - @EnvironmentObject var theme: AppTheme - var result: NameSearchResult - - var body: some View { - HStack(spacing: 8) { - ProfileImage(imageStr: result.image, icon: Image(systemName: 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 DirectoryPageInChatsRow: View { @EnvironmentObject var theme: AppTheme @@ -319,7 +265,7 @@ struct DirectoryPageInChatsRow: View { } } -struct DirectoryShowMoreRow: View { +struct DirectoryLoadMoreRow: View { @EnvironmentObject var theme: AppTheme @ObservedObject var onlineSearch: OnlineSearchModel @@ -327,11 +273,22 @@ struct DirectoryShowMoreRow: View { Button { Task { await onlineSearch.loadMore() } } label: { - Text("Show more") - .foregroundColor(onlineSearch.loading ? theme.colors.secondary : theme.colors.primary) + HStack(spacing: 4) { + Image(systemName: "plus.magnifyingglass") + Text(loadMoreText) + } + .foregroundColor(onlineSearch.loading ? theme.colors.secondary : theme.colors.primary) } .disabled(onlineSearch.loading) } + + private var loadMoreText: String { + if let n = onlineSearch.moreCount, n > 0 { + String.localizedStringWithFormat(NSLocalizedString("Load more · %d left", comment: "directory search results"), n) + } else { + NSLocalizedString("Load more", comment: "directory search results") + } + } } struct DirectoryRetryRow: View { @@ -342,14 +299,48 @@ struct DirectoryRetryRow: View { Button { Task { await onlineSearch.retry() } } label: { - Text("Search failed, tap to retry") - .foregroundColor(theme.colors.primary) + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + Text("Search failed, tap to retry") + } + .foregroundColor(theme.colors.primary) } } } +struct DirectoryResultMenu: ViewModifier { + @EnvironmentObject var theme: AppTheme + var result: DirectoryResult + var imageSize: CGFloat + var onOpen: () -> Void + + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content.contextMenu { + Button(action: onOpen) { + Label("Open", systemImage: "message") + } + } preview: { + DirectorySearchRow(entry: result.entry, imageSize: imageSize, state: result.state, showsOpen: false) + .padding() + .frame(width: UIScreen.main.bounds.width - 32) + .background(theme.colors.background) + .environmentObject(theme) + } + } else { + content + } + } +} + +// a pending join keeps the sheet or alert the connection flow shows for it +func openDirectoryResult(_ result: DirectoryResult, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) { + let prepareGroup: PrepareGroupMode = if case .joining = result.state { .confirm } else { .open } + planAndConnect(result.entry.link, theme: theme, dismiss: dismiss, cleanup: cleanup, prepareGroup: prepareGroup) +} + func confirmDirectorySearch(_ search: @escaping () -> Void) { - if UserDefaults.standard.bool(forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN) { + if UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_DIRECTORY_SEARCH) { search() } else { showDirectorySearchAlert(onSearch: search) @@ -358,20 +349,15 @@ func confirmDirectorySearch(_ search: @escaping () -> Void) { 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"), + NSLocalizedString("Enable Directory search?", comment: "alert title"), + message: NSLocalizedString("Search text is sent to SimpleX Directory, each search separately and without your profile, so it can't build your search history.", 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"), + title: NSLocalizedString("Enable", comment: "alert action"), style: .default, handler: { _ in - UserDefaults.standard.set(true, forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN) + UserDefaults.standard.set(true, forKey: DEFAULT_PRIVACY_DIRECTORY_SEARCH) onSearch() } ) diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 84a4332789..10b3faaead 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -93,10 +93,10 @@ struct NewChatSheet: View { searchShowingSimplexLink: $searchShowingSimplexLink, searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink, connectNameCandidate: $connectNameCandidate, - onSearch: runOnlineSearch + onSearch: runDirectorySearch ) .frame(maxWidth: .infinity) - if let candidate = connectNameCandidate, onlineSearch.names.isEmpty { + if let candidate = connectNameCandidate { ConnectByNameRow( name: candidate, searchText: $searchText, @@ -105,7 +105,7 @@ struct NewChatSheet: View { dismiss: true ) } - if !searchTrimmed.isEmpty, !searchShowingSimplexLink { + if !searchTrimmed.isEmpty, !searchShowingSimplexLink, !onlineSearch.showResults { SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch) } } @@ -182,7 +182,7 @@ struct NewChatSheet: View { showDeletedChatIcon: true, showsEmptyState: !onlineSearch.searching && rows.isEmpty ) - onlineSearchSections(rows) + onlineSearchSection(rows) } .onChange(of: searchText) { _ in onlineSearch.reset() @@ -192,49 +192,30 @@ struct NewChatSheet: View { } } - @ViewBuilder private func onlineSearchSections(_ rows: OnlineSearchRows) -> some View { - if !rows.names.isEmpty { + @ViewBuilder private func onlineSearchSection(_ rows: OnlineSearchRows) -> some View { + if !rows.isEmpty { Section { - ForEach(rows.names) { result in - NameSearchRow(result: result) - .onTapGesture { - searchFocussed = false - planAndConnect(result.target, theme: theme, dismiss: true, cleanup: nil) - } + ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in + ContactListNavLink(chat: chat, showDeletedChatIcon: true) } - } header: { - onlineSearchHeader("Names") - } - } - if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil { - Section { - ForEach(rows.directory) { result in - if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) { - ContactListNavLink(chat: chat, showDeletedChatIcon: true) - } else { - DirectorySearchRow(entry: result.entry) - .onTapGesture { - searchFocussed = false - planAndConnect(result.entry.link, theme: theme, dismiss: true, cleanup: nil) - } - } + ForEach(rows.results) { result in + DirectorySearchRow(entry: result.entry, imageSize: 30, state: result.state) + .modifier(DirectoryResultMenu(result: result, imageSize: 30, onOpen: { openResult(result) })) + .onTapGesture { openResult(result) } } if rows.pageInChats { DirectoryPageInChatsRow() } switch rows.footer { case .retry: DirectoryRetryRow(onlineSearch: onlineSearch) - case .showMore: DirectoryShowMoreRow(onlineSearch: onlineSearch) + case .loadMore: DirectoryLoadMoreRow(onlineSearch: onlineSearch) case nil: EmptyView() } - } header: { - onlineSearchHeader("Directory") } } } - private func onlineSearchHeader(_ title: LocalizedStringKey) -> some View { - Text(title) - .textCase(.uppercase) - .foregroundColor(theme.colors.secondary) + private func openResult(_ result: DirectoryResult) { + searchFocussed = false + openDirectoryResult(result, theme: theme, dismiss: true, cleanup: nil) } private var searchTrimmed: String { @@ -247,13 +228,6 @@ struct NewChatSheet: View { confirmDirectorySearch { Task { await onlineSearch.searchDirectory(text) } } } - private func runOnlineSearch() { - let text = searchTrimmed - guard !text.isEmpty, !searchShowingSimplexLink else { return } - let name = connectNameCandidate - confirmDirectorySearch { Task { await onlineSearch.search(text, name: name) } } - } - private func contactListChatPredicate(_ chat: Chat, _ withSearch: Bool) -> Bool { switch chat.chatInfo { case .contactRequest: true diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index f2d31118bf..d2fd9706c5 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1334,6 +1334,10 @@ private func showOpenKnownGroupAlert( ) } +enum PrepareGroupMode { + case confirm, open +} + // Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, @@ -1342,7 +1346,8 @@ func planAndConnect( dismiss: Bool, cleanup: (() -> Void)? = nil, filterKnownContact: ((Contact) -> Void)? = nil, - filterKnownGroup: ((GroupInfo) -> Void)? = nil + filterKnownGroup: ((GroupInfo) -> Void)? = nil, + prepareGroup: PrepareGroupMode = .confirm ) { switch strConnectTarget(shortOrFullLink) { case let .link(_, linkType, _): @@ -1543,18 +1548,30 @@ func planAndConnect( if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { - showPrepareGroupAlert( - connectionLink: connectionLink, - groupShortLinkInfo: groupShortLinkInfo_, - groupShortLinkData: groupSLinkData, - ownerVerification: ownerVerification, - verifiedDomain: planSimplexName?.nameDomain, - connectOtherButton: connectOtherButton, - connectOtherLink: connectOtherLink, - theme: theme, - dismiss: dismiss, - cleanup: cleanup - ) + switch prepareGroup { + case .confirm: + showPrepareGroupAlert( + connectionLink: connectionLink, + groupShortLinkInfo: groupShortLinkInfo_, + groupShortLinkData: groupSLinkData, + ownerVerification: ownerVerification, + verifiedDomain: planSimplexName?.nameDomain, + connectOtherButton: connectOtherButton, + connectOtherLink: connectOtherLink, + theme: theme, + dismiss: dismiss, + cleanup: cleanup + ) + case .open: + prepareAndOpenGroup( + connectionLink: connectionLink, + groupShortLinkInfo: groupShortLinkInfo_, + groupShortLinkData: groupSLinkData, + verifiedDomain: planSimplexName?.nameDomain, + dismiss: dismiss, + cleanup: cleanup + ) + } } } else { logger.debug("planAndConnect, .groupLink, .ok, no short link data") diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index bf3dbac440..139328a492 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -17,6 +17,7 @@ struct PrivacySettings: View { @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = false + @AppStorage(DEFAULT_PRIVACY_DIRECTORY_SEARCH) private var directorySearch = false @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true @AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true @@ -83,6 +84,9 @@ struct PrivacySettings: View { privacyLinkPreviewsShowAlertGroupDefault.set(false) } } + settingsRow("magnifyingglass", color: theme.colors.secondary) { + Toggle("Directory search", isOn: $directorySearch) + } settingsRow("link", color: theme.colors.secondary) { Toggle("Remove link tracking", isOn: $privacySanitizeLinks) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index b1b786c8b8..675ecf429e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -41,6 +41,7 @@ let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius" +let DEFAULT_PRIVACY_DIRECTORY_SEARCH = "privacyDirectorySearch" let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls" let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName" let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime" @@ -59,7 +60,6 @@ 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_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" let DEFAULT_SUPPORTER_BANNER_TAPPED = "supporterBannerTapped" let DEFAULT_GET_STAKE_BANNER_TAPPED = "getStakeBannerTapped" @@ -117,6 +117,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false, DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0, + DEFAULT_PRIVACY_DIRECTORY_SEARCH: false, DEFAULT_EXPERIMENTAL_CALLS: false, DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue, DEFAULT_DEVELOPER_TOOLS: false, @@ -126,7 +127,6 @@ 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_SUPPORTER_BANNER_SHOWN: false, DEFAULT_SUPPORTER_BANNER_TAPPED: false, DEFAULT_GET_STAKE_BANNER_TAPPED: false, @@ -163,7 +163,6 @@ let hintDefaults = [ DEFAULT_LA_NOTICE_SHOWN, DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, - DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN, DEFAULT_SUPPORTER_BANNER_SHOWN, DEFAULT_SUPPORTER_BANNER_TAPPED, DEFAULT_GET_STAKE_BANNER_TAPPED,