ios: show directory results only with rows, and only group links

A directory result is untrusted input: an entry is kept only when its
link is a group or channel link, because the core fetches a one-time
invitation link even when asked for a store-only plan, and the next
slice looks every entry up in the store. The entry's link is no longer
optional, so the tap needs no unwrap and the id no fallback.

The Directory part now renders only when it has a row: no header while
loading, no "No results" row; the empty-state overlay shows when the
search finished with nothing. Its header is a row rather than a sticky
section header, which in the flipped one-hand list would pin at an
unverified position. Rows say "Join channel" or "Join group". A pasted
link in one-hand mode leaves the tags in place.

Run: 002-ios-directory-search-ui, slice 2
This commit is contained in:
spaced4ndy
2026-10-13 11:33:26 +00:00
parent 9e5d7fe831
commit 8bfae1fb53
3 changed files with 74 additions and 38 deletions
+29 -5
View File
@@ -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
@@ -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
@@ -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())