Files
simplex-chat/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift
T
spaced4ndy 54ed1d846a review fixes
- core: reject service payloads nested deeper than 32 levels, so a hostile service cannot crash the app's JSON decoder
- core: one parseServiceBody (decompress, decode, depth check) at all three decode points: incoming requests, sendServiceRequestTo, redeemBadgeCode
- directory: '%' and '_' in search text match literally instead of acting as LIKE wildcards
- directory: search text over 100 characters is rejected over RPC
- directory: a new directory creates its address with DR keys, so service requests work without a manual key rotation
- directory: service requests are always processed; the --service-requests switch is no longer needed for the directory
- directory: the in-flight service request cap is a DirectoryOpts field (default 8) instead of a hardcoded constant
- directory: the page-fit check compresses the exact response object that is sent, not a proxy with a different key order and cursor
- directory: search entries omit the full link when a short link exists, saving hundreds of bytes per entry in the envelope
- ios: add DirectorySearch.swift and DirectorySearchView.swift to the Xcode project; the app did not build without them
- ios: in the onboarding state the search bar is at the bottom in one-hand mode, as in the chat list
- ios: in the onboarding state a failed search shows the retry row instead of returning to the cards
- ios: a search with no results shows a "No results" row
- ios: the "No chats found" overlay no longer covers directory results, the retry row or the no-results row
- ios: directory rows are rendered by one builder for both the onboarding and chat-list branches
- ios: retrying after a failed "Show more" resumes from the cursor instead of restarting the search
- ios: cancelling the spinner, or tapping a result mid-search, drops the pending request without clearing results already shown
- ios: typing during a search stops the spinner, and a stale response no longer clears a newer search's spinner
- ios: repeated Search taps for the same text while a search is in flight are ignored
- ios: channel results show the subscriber count and channel icon; group results show the group icon
- ios: remove the unused searchText parameter of SearchInDirectoryRow
- android: a directory search timeout no longer pops the retry alert (the UI will show a retry row)
- tests: RPC paging by the echoed cursor, literal wildcards and the length bound, rejection over the cap, page fitting to the envelope, nested payload rejection
2026-09-18 16:45:42 +04:00

186 lines
6.6 KiB
Swift

//
// 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 }
var showResults: Bool { loading || searched }
func reset() {
generation += 1
entries = []
cursor = nil
loading = false
failed = false
searched = false
searchedText = ""
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
}
// a way out of the wait, not out of the results: the page already shown stays
func cancelRequest() {
generation += 1
loading = false
}
func search(_ text: String) async {
let text = text.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !(loading && text == searchedText) 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?.cancelRequest() }
}
let r = await apiSearchDirectory(searchedText, cursor: cursor)
guard gen == generation else { return }
ConnectProgressManager.shared.stopConnectProgress(.directorySearch)
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
@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,
iconName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill",
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 isChannel: Bool { entry.entryType.groupType == .channel }
private var membersText: String {
let summary = entry.entryType.summary
let count = summary.publicMemberCount ?? summary.currentMembers
return isChannel
? subscriberCountStr(count)
: String.localizedStringWithFormat(NSLocalizedString("%d members", comment: "directory search result"), Int(count))
}
}
// 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()
}
)
]}
)
}