mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-29 08:59:12 +00:00
- 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
186 lines
6.6 KiB
Swift
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()
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
}
|