diff --git a/apps/ios/Shared/Model/DirectorySearch.swift b/apps/ios/Shared/Model/DirectorySearch.swift index cd6dc3b3f7..6b80090c15 100644 --- a/apps/ios/Shared/Model/DirectorySearch.swift +++ b/apps/ios/Shared/Model/DirectorySearch.swift @@ -30,17 +30,41 @@ struct DirectorySearchEntry: Decodable, Hashable, Identifiable { var entryType: DirectoryEntryType var displayName: String var simplexName: String? = nil - var groupLink: DirectoryPublicLink + let link: String var shortDescr: String? = nil var image: String? = nil var activeAt: Date? = nil var createdAt: Date? = nil - // the directory drops entries with no link, but the response is untrusted input - var connectLink: String? { groupLink.connShortLink ?? groupLink.connFullLink } - // the link is stable and unique per entry, so it also de-duplicates across pages - var id: String { connectLink ?? displayName } + var id: String { link } + + enum CodingKeys: String, CodingKey { + case entryType, displayName, simplexName, groupLink, shortDescr, image, activeAt, createdAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + entryType = try c.decode(DirectoryEntryType.self, forKey: .entryType) + displayName = try c.decode(String.self, forKey: .displayName) + simplexName = try c.decodeIfPresent(String.self, forKey: .simplexName) + shortDescr = try c.decodeIfPresent(String.self, forKey: .shortDescr) + image = try c.decodeIfPresent(String.self, forKey: .image) + activeAt = try c.decodeIfPresent(Date.self, forKey: .activeAt) + createdAt = try c.decodeIfPresent(Date.self, forKey: .createdAt) + let groupLink = try c.decode(DirectoryPublicLink.self, forKey: .groupLink) + // untrusted input: only group and channel links are kept, as the core fetches an invitation link even in a store-only plan + guard let link = groupLink.connShortLink ?? groupLink.connFullLink, + let linkType = simplexLinkType(link), + linkType == .group || linkType == .channel else { + throw DecodingError.dataCorruptedError(forKey: .groupLink, in: c, debugDescription: "not a group or channel link") + } + self.link = link + } +} + +func simplexLinkType(_ s: String) -> SimplexLinkType? { + if case let .link(text, linkType, _) = strConnectTarget(s), text == s { linkType } else { nil } } // entries stay undecoded here so they can be decoded one at a time: one entry the app does not diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index dcef0fa715..961c2344c1 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -447,7 +447,18 @@ struct ChatListView: View { .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .padding(.horizontal) if onlineSearch.showResults { - List { directoryRows() }.listStyle(.plain) + List { + directoryRows() + if !onlineSearch.loading && onlineSearch.entries.isEmpty && onlineSearch.footer == nil { + Text("No chats found") + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) } else { VStack(spacing: 0) { ConnectOnboardingView() @@ -468,25 +479,29 @@ struct ChatListView: View { } @ViewBuilder private func directoryRows() -> some View { - ForEach(onlineSearch.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 onlineSearch.failed { - directoryRetryRow() - } else if onlineSearch.hasMore { - directoryShowMoreRow() - } else if onlineSearch.searched && onlineSearch.entries.isEmpty { - Text("No results") + let footer = onlineSearch.footer + if !onlineSearch.entries.isEmpty || footer != nil { + Text("Directory") + .font(.callout) .foregroundColor(theme.colors.secondary) + .accessibilityAddTraits(.isHeader) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) .listRowBackground(Color.clear) + ForEach(onlineSearch.entries) { entry in + DirectorySearchRow(entry: entry) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowBackground(Color.clear) + .onTapGesture { + searchFocussed = false + planAndConnect(entry.link, theme: theme, dismiss: false, cleanup: nil) + } + } + switch footer { + case .retry: directoryRetryRow() + case .showMore: directoryShowMoreRow() + case nil: EmptyView() + } } } @@ -599,14 +614,7 @@ struct ChatListView: View { .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) } } - if onlineSearch.showResults { - Section { - directoryRows() - } header: { - Text("Directory") - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - } - } + directoryRows() if !addressCreationCardShown && hasConversations { ConnectBannerCard() .padding(.vertical, 6) @@ -634,7 +642,7 @@ struct ChatListView: View { } } // this covers the whole list, so it must not hide the directory section or its own messages - if cs.isEmpty && !chatModel.chats.isEmpty && !onlineSearch.showResults { + if cs.isEmpty && !chatModel.chats.isEmpty && !onlineSearch.loading && onlineSearch.entries.isEmpty && onlineSearch.footer == nil { noChatsView() .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .foregroundColor(.secondary) @@ -841,7 +849,8 @@ struct ChatListSearchBar: View { dismiss: false ) searchInDirectoryRow() - } else if oneHandUI, !searchTrimmed.isEmpty { + // a SimpleX link connects on its own, so the directory is not offered for one + } else if oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink { searchInDirectoryRow() } else { ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } @@ -891,7 +900,7 @@ struct ChatListSearchBar: View { dismiss: false ) } - if !oneHandUI, !searchTrimmed.isEmpty { + if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink { searchInDirectoryRow() } } @@ -999,10 +1008,7 @@ struct ChatListSearchBar: View { } @ViewBuilder private func searchInDirectoryRow() -> some View { - // a SimpleX link connects on its own, so the directory is not offered for one - if !searchShowingSimplexLink { - SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch) - } + SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch) } // whatever is typed here gets sent to the directory, so ask before the first time diff --git a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift index e82ff5cd4d..5fb810240c 100644 --- a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift +++ b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift @@ -9,6 +9,10 @@ import SwiftUI import SimpleXChat +enum DirectoryFooter { + case retry, showMore +} + // Directory results are not chats and are never saved: they live as long as the search text does. @MainActor class OnlineSearchModel: ObservableObject { @@ -23,8 +27,8 @@ class OnlineSearchModel: ObservableObject { // bumped on every reset, so a reply that arrives too late cannot refill a list already cleared private var generation = 0 - var hasMore: Bool { cursor != nil } var showResults: Bool { loading || searched } + var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .showMore : nil } func reset() { generation += 1 @@ -144,6 +148,8 @@ struct DirectorySearchRow: View { .foregroundColor(theme.colors.secondary) } Spacer() + Text(isChannel ? "Join channel" : "Join group") + .foregroundColor(theme.colors.primary) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle())