mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-16 08:50:37 +00:00
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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user