ios: online search shows one list of directory results with Open, and asks to enable Directory search

The Search key and "Search in Directory" search the directory only; a SimpleX name is looked up with "Connect to …", which now stays next to the results. The first search asks to enable Directory search, a new switch in Privacy settings. Results form one list without headers: your chats first, then the others at the chat row's icon size with Open, which opens the prepared chat without the "Open channel?" alert, or "Already joining" for a pending join. The load row says how many results are left; a long press shows Open. The prepared channel's banner shows its subscriber count.

Run: 011-search-redesign, slice 3
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-20 23:46:58 +00:00
1 parent cf30d58579
commit c92d3a4113
8 files changed
+216 -302

No files matched your search

+20 -49
View File
@@ -73,12 +73,14 @@ private struct DirectorySearchResponse: Decodable {
var type: String
var entries: [JSONValue]?
var searchCursor: JSONValue?
var moreCount: JSONValue?
}
struct DirectorySearchResults {
var entries: [DirectorySearchEntry]
// opaque: stored and echoed back on the next request, never inspected
var cursor: JSONValue?
var moreCount: Int?
}
func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String {
@@ -96,7 +98,9 @@ func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults?
let entries = (r.entries ?? []).compactMap { (e: JSONValue) -> DirectorySearchEntry? in
decodeJSONValue(e)
}
return DirectorySearchResults(entries: entries, cursor: r.searchCursor)
// only shown as text, so a count out of range is dropped rather than failing the response
let moreCount: Int? = if case let .number(n)? = r.moreCount, n >= 0, n <= Double(Int32.max), n == n.rounded() { Int(n) } else { nil }
return DirectorySearchResults(entries: entries, cursor: r.searchCursor, moreCount: moreCount)
}
func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? {
@@ -122,66 +126,33 @@ func planLocalChatId(_ plan: ConnectionPlan) -> ChatId? {
}
}
struct NameSearchResult: Identifiable {
let target: String
let simplexName: String
let profileName: String?
let descr: String?
let image: String?
let iconName: String
var localChatId: ChatId?
// the link the core resolved the name to, never a profile field the name's owner wrote
let resolvedLink: String?
enum DirectoryResultState {
case yourChat(ChatId)
case joining
case open
}
var id: String { target }
init?(target: String, result: ConnectionPlanResult) {
let profile: (any NamedChat)?
switch result.connectionPlan {
case let .contactAddress(contactAddressPlan):
switch contactAddressPlan {
case let .ok(contactSLinkData_, _): profile = contactSLinkData_?.profile
case let .contactViaAddress(contact), let .connectingProhibit(contact), let .known(contact): profile = contact.profile
case .connectingConfirmReconnect: profile = nil
case .ownLink: return nil
}
iconName = "person.crop.circle.fill"
resolvedLink = nil
case let .groupLink(groupLinkPlan):
let groupProfile: GroupProfile?
switch groupLinkPlan {
case let .ok(_, groupSLinkData_, _), let .noRelays(groupSLinkData_), let .updateRequired(groupSLinkData_): groupProfile = groupSLinkData_?.groupProfile
case let .known(groupInfo), let .ownLink(groupInfo): groupProfile = groupInfo.groupProfile
case let .connectingProhibit(groupInfo_): groupProfile = groupInfo_?.groupProfile
case .connectingConfirmReconnect: groupProfile = nil
}
profile = groupProfile
iconName = groupProfile?.isChannel == true ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"
resolvedLink = result.connLink.connShortLink
case .invitationLink, .error:
return nil
}
self.target = target
simplexName = result.planSimplexName?.shortStr ?? target
profileName = profile?.displayName
descr = profile?.shortDescr
image = profile?.image
localChatId = planLocalChatId(result.connectionPlan)
// no plan means the store doesn't know the link, or the plan failed and Open's online plan will show why
func directoryResultState(_ plan: ConnectionPlan?) -> DirectoryResultState {
if let chatId = plan.flatMap(planLocalChatId) { return .yourChat(chatId) }
switch plan {
case .groupLink(.connectingConfirmReconnect)?, .groupLink(.connectingProhibit(nil))?: return .joining
default: return .open
}
}
struct DirectoryResult: Identifiable {
let entry: DirectorySearchEntry
var localChatId: ChatId?
var state: DirectoryResultState
var id: String { entry.id }
}
struct OnlineSearchRows {
var names: [NameSearchResult]
var directory: [DirectoryResult]
var yourChats: [ChatId]
var results: [DirectoryResult]
var pageInChats: Bool
var footer: DirectoryFooter?
var isEmpty: Bool { names.isEmpty && directory.isEmpty && !pageInChats && footer == nil }
var isEmpty: Bool { yourChats.isEmpty && results.isEmpty && !pageInChats && footer == nil }
}
@@ -1027,6 +1027,14 @@ struct ChatView: View {
.frame(maxWidth: 260)
}
if case let .group(groupInfo, _) = chat.chatInfo,
groupInfo.nextConnectPrepared && groupInfo.useRelays,
let count = groupInfo.groupSummary.publicMemberCount {
Text(subscriberCountStr(count))
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
}
ProfileDescriptionView(shortDescr: chat.chatInfo.shortDescr, description: chat.chatInfo.profileDescription)
.padding(.horizontal)
@@ -170,6 +170,7 @@ struct ChatListView: View {
@StateObject private var chatTagsModel = ChatTagsModel.shared
@StateObject private var onlineSearch = OnlineSearchModel()
@State private var scrollToItemId: ChatItem.ID? = nil
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
@State private var viewOnScreen = true
@@ -449,7 +450,6 @@ struct ChatListView: View {
if onlineSearch.showResults {
let rows = onlineSearch.rows(shownChatIds: [])
List {
nameRows(rows.names)
directoryRows(rows)
if !onlineSearch.searching && rows.isEmpty {
Text("No chats found")
@@ -480,67 +480,30 @@ struct ChatListView: View {
}
}
@ViewBuilder private func nameRows(_ names: [NameSearchResult]) -> some View {
if !names.isEmpty {
searchPart("Names") {
ForEach(names) { result in
NameSearchRow(result: result)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.target, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
}
}
}
}
}
@ViewBuilder private func directoryRows(_ rows: OnlineSearchRows) -> some View {
if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil {
searchPart("Directory") {
ForEach(rows.directory) { result in
if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) {
chatListNavLink(chat)
} else {
DirectorySearchRow(entry: result.entry)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
}
}
}
if rows.pageInChats { directoryPageInChatsRow() }
switch rows.footer {
case .retry: directoryRetryRow()
case .showMore: directoryShowMoreRow()
case nil: EmptyView()
}
}
ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in
chatListNavLink(chat)
}
let imageSize = dynamicSize(userFont).profileImageSize
ForEach(rows.results) { result in
// the menu's preview is drawn upright only when the menu is attached before the row is flipped
DirectorySearchRow(entry: result.entry, imageSize: imageSize, state: result.state)
.modifier(DirectoryResultMenu(result: result, imageSize: imageSize, onOpen: { openResult(result) }))
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture { openResult(result) }
}
if rows.pageInChats { directoryPageInChatsRow() }
switch rows.footer {
case .retry: directoryRetryRow()
case .loadMore: directoryLoadMoreRow()
case nil: EmptyView()
}
}
// the list is flipped in one-hand UI, so there the header follows its rows to be drawn above them
@ViewBuilder private func searchPart<Content: View>(_ title: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
if oneHandUI {
content()
searchPartHeader(title)
} else {
searchPartHeader(title)
content()
}
}
private func searchPartHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.callout)
.foregroundColor(theme.colors.secondary)
.accessibilityAddTraits(.isHeader)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
private func openResult(_ result: DirectoryResult) {
searchFocussed = false
openDirectoryResult(result, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshStates() } })
}
private func openGetStake() {
@@ -636,7 +599,6 @@ struct ChatListView: View {
.onAppear { chatModel.chatListBanner = .getStake }
}
ForEach(cs, id: \.viewId) { chat in chatListNavLink(chat) }
nameRows(rows.names)
directoryRows(rows)
if showsCards && !addressCreationCardShown && hasConversations {
ConnectBannerCard()
@@ -697,8 +659,8 @@ struct ChatListView: View {
.listRowBackground(Color.clear)
}
@ViewBuilder private func directoryShowMoreRow() -> some View {
DirectoryShowMoreRow(onlineSearch: onlineSearch)
@ViewBuilder private func directoryLoadMoreRow() -> some View {
DirectoryLoadMoreRow(onlineSearch: onlineSearch)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
}
@@ -887,7 +849,7 @@ struct ChatListSearchBar: View {
VStack(spacing: 12) {
// a typed name shows a row to connect to it (as on Android mobile): with the reachable toolbar it
// replaces the tags above the search field; in top bar mode the tags stay and it moves below (end of VStack)
if oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
if oneHandUI, let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
@@ -895,10 +857,10 @@ struct ChatListSearchBar: View {
searchFocussed: $searchFocussed,
dismiss: false
)
searchInDirectoryRow()
if !onlineSearch.showResults { searchInDirectoryRow() }
// a SimpleX link connects on its own, so the directory is not offered for one
} else if oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink {
searchInDirectoryRow()
if !onlineSearch.showResults { searchInDirectoryRow() }
} else {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
}
@@ -911,7 +873,7 @@ struct ChatListSearchBar: View {
.focused($searchFocussed)
.frame(maxWidth: .infinity)
.submitLabel(.search)
.onSubmit(runOnlineSearch)
.onSubmit(runDirectorySearch)
if connectProgressManager.showConnectProgress != nil {
ProgressView()
}
@@ -938,7 +900,7 @@ struct ChatListSearchBar: View {
toggleFilterButton()
}
}
if !oneHandUI, let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
if !oneHandUI, let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
@@ -947,7 +909,7 @@ struct ChatListSearchBar: View {
dismiss: false
)
}
if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink {
if !oneHandUI, !searchTrimmed.isEmpty, !searchShowingSimplexLink, !onlineSearch.showResults {
searchInDirectoryRow()
}
}
@@ -1058,19 +1020,12 @@ struct ChatListSearchBar: View {
SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch)
}
// whatever is typed here gets sent to the directory, so ask before the first time
// whatever is typed here gets sent to the directory, so ask until Directory search is enabled
private func runDirectorySearch() {
let text = searchTrimmed
guard !text.isEmpty, !searchShowingSimplexLink else { return }
confirmDirectorySearch { Task { await onlineSearch.searchDirectory(text) } }
}
private func runOnlineSearch() {
let text = searchTrimmed
guard !text.isEmpty, !searchShowingSimplexLink else { return }
let name = connectNameCandidate
confirmDirectorySearch { Task { await onlineSearch.search(text, name: name) } }
}
}
// Row shown when the search text is a SimpleX name — in place of the list tags in the chat list, below
@@ -10,16 +10,15 @@ import SwiftUI
import SimpleXChat
enum DirectoryFooter {
case retry, showMore
case retry, loadMore
}
// Online results are not chats and are never saved: they live as long as the search text does.
@MainActor
class OnlineSearchModel: ObservableObject {
@Published private(set) var names: [NameSearchResult] = []
@Published private(set) var directory: [DirectoryResult] = []
@Published private(set) var moreCount: Int? = nil
@Published private(set) var loading = false { didSet { updateSpinner() } }
@Published private(set) var resolvingNames = false { didSet { updateSpinner() } }
@Published private(set) var failed = false
// tells "nothing searched yet" apart from "searched and found nothing"
@Published private(set) var searched = false
@@ -31,18 +30,17 @@ class OnlineSearchModel: ObservableObject {
private var generation = 0
private var spinnerShown = false
var searching: Bool { loading || resolvingNames }
var showResults: Bool { searching || searched || !names.isEmpty }
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .showMore : nil }
var searching: Bool { loading }
var showResults: Bool { loading || searched }
var footer: DirectoryFooter? { failed ? .retry : cursor != nil ? .loadMore : nil }
func reset() {
generation += 1
names = []
directory = []
moreCount = nil
cursor = nil
lastPageIds = []
loading = false
resolvingNames = false
failed = false
searched = false
searchedText = ""
@@ -52,24 +50,6 @@ class OnlineSearchModel: ObservableObject {
func cancelRequest() {
generation += 1
loading = false
resolvingNames = false
}
func search(_ text: String, name: String?) async {
let text = text.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !(searching && text == searchedText) else { return }
reset()
searchedText = text
if let name {
resolvingNames = true
// captured here: the async let initializer runs later, in the child task, possibly after a reset
let gen = generation
async let resolution: Void = resolveNames(name, generation: gen)
await request(append: false)
await resolution
} else {
await request(append: false)
}
}
func searchDirectory(_ text: String) async {
@@ -77,6 +57,7 @@ class OnlineSearchModel: ObservableObject {
guard !text.isEmpty, !(loading && text == searchedText) else { return }
if text == searchedText {
directory = []
moreCount = nil
cursor = nil
lastPageIds = []
failed = false
@@ -93,7 +74,7 @@ class OnlineSearchModel: ObservableObject {
await request(append: true)
}
// a failed "Show more" keeps its cursor, so the retry resumes the page rather than starting over
// a failed "Load more" keeps its cursor, so the retry resumes the page rather than starting over
func retry() async {
if cursor != nil {
await loadMore()
@@ -103,68 +84,50 @@ class OnlineSearchModel: ObservableObject {
}
func rows(shownChatIds: Set<ChatId>) -> OnlineSearchRows {
let nameChatIds = Set(names.compactMap { $0.localChatId })
let nameLinks = Set(names.compactMap { $0.resolvedLink })
func isAmong(_ ids: Set<ChatId>, _ id: ChatId?) -> Bool {
id.map { ids.contains($0) } ?? false
var yourChats: [ChatId] = []
var results: [DirectoryResult] = []
for result in directory {
switch result.state {
case let .yourChat(chatId) where ChatModel.shared.getChat(chatId) != nil:
if !shownChatIds.contains(chatId) && !yourChats.contains(chatId) {
yourChats.append(chatId)
}
case .yourChat:
results.append(DirectoryResult(entry: result.entry, state: .open))
case .joining, .open:
results.append(result)
}
}
let lastPage = directory.filter { lastPageIds.contains($0.id) }
return OnlineSearchRows(
names: names.filter { !isAmong(shownChatIds, $0.localChatId) },
directory: directory.filter {
!isAmong(shownChatIds, $0.localChatId) && !isAmong(nameChatIds, $0.localChatId) && !nameLinks.contains($0.entry.link)
},
// a result hidden only because a name resolved to its link is on screen, but not one of your chats
pageInChats: !lastPage.isEmpty && lastPage.allSatisfy {
isAmong(shownChatIds, $0.localChatId) || isAmong(nameChatIds, $0.localChatId)
yourChats: yourChats,
results: results,
pageInChats: !lastPage.isEmpty && lastPage.allSatisfy { result in
if case let .yourChat(chatId) = result.state { shownChatIds.contains(chatId) } else { false }
},
footer: footer
)
}
// a chat opened from a result exists only once its connection flow ends, so rows without a chat look again then
func refreshLocalChatIds() async {
// a chat opened from a result exists only once its connection flow ends, so results look again then
func refreshStates() async {
let gen = generation
var nameChatIds: [String: ChatId] = [:]
for result in names where result.localChatId == nil {
nameChatIds[result.id] = await localChatId(result.target)
}
var directoryChatIds: [String: ChatId] = [:]
for result in directory where result.localChatId == nil {
directoryChatIds[result.id] = await localChatId(result.entry.link)
var states: [String: DirectoryResultState] = [:]
for result in directory {
if case .yourChat = result.state { continue }
states[result.id] = await resultState(result.entry.link)
}
guard gen == generation else { return }
names = names.map { result in
var r = result
r.localChatId = r.localChatId ?? nameChatIds[r.id]
return r
}
directory = directory.map { result in
var r = result
r.localChatId = r.localChatId ?? directoryChatIds[r.id]
r.state = states[r.id] ?? r.state
return r
}
}
private func localChatId(_ link: String) async -> ChatId? {
private func resultState(_ link: String) async -> DirectoryResultState {
let plan = await apiConnectPlan(connLink: link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log)
return plan.flatMap { planLocalChatId($0.connectionPlan) }
}
private func resolveNames(_ name: String, generation gen: Int) async {
let targets = name.hasPrefix("@") || name.hasPrefix("#") ? [name] : ["@" + name, "#" + name]
var results: [NameSearchResult] = []
for target in targets {
// checked before each target: a cancelled or replaced search must not send more names to the server
guard gen == generation else { return }
if let r = await apiConnectPlan(connLink: target, resolveMode: .unknown, inProgress: BoxedValue(false), onFailure: .log),
let result = NameSearchResult(target: target, result: r) {
results.append(result)
}
}
guard gen == generation else { return }
resolvingNames = false
names = results
return directoryResultState(plan?.connectionPlan)
}
private func request(append: Bool) async {
@@ -177,7 +140,7 @@ class OnlineSearchModel: ObservableObject {
var seen = Set(directory.map { $0.id })
for entry in r.entries {
guard seen.insert(entry.id).inserted else { continue }
page.append(DirectoryResult(entry: entry, localChatId: await localChatId(entry.link)))
page.append(DirectoryResult(entry: entry, state: await resultState(entry.link)))
}
}
guard gen == generation else { return }
@@ -188,6 +151,7 @@ class OnlineSearchModel: ObservableObject {
return
}
cursor = r.cursor
moreCount = r.moreCount
directory = append ? directory + page : page
lastPageIds = Set(page.map { $0.id })
}
@@ -233,13 +197,16 @@ struct SearchInDirectoryRow: View {
struct DirectorySearchRow: View {
@EnvironmentObject var theme: AppTheme
var entry: DirectorySearchEntry
var imageSize: CGFloat
var state: DirectoryResultState
var showsOpen = true
var body: some View {
HStack(spacing: 8) {
ProfileImage(
imageStr: entry.image,
icon: Image(systemName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"),
size: 42
size: imageSize
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
@@ -256,15 +223,17 @@ struct DirectorySearchRow: View {
.foregroundColor(theme.colors.secondary)
.lineLimit(2)
}
Text(membersText)
Text(joining ? NSLocalizedString("Already joining", comment: "directory search result") : membersText)
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
Spacer()
Text(isChannel ? "Join channel" : "Join group")
.foregroundColor(theme.colors.primary)
.lineLimit(1)
.fixedSize()
if showsOpen && !joining {
Text("Open")
.foregroundColor(theme.colors.primary)
.lineLimit(1)
.fixedSize()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
@@ -274,6 +243,10 @@ struct DirectorySearchRow: View {
private var isChannel: Bool { entry.entryType.groupType == .channel }
private var joining: Bool {
if case .joining = state { true } else { false }
}
private var membersText: String {
let summary = entry.entryType.summary
let count = summary.publicMemberCount ?? summary.currentMembers
@@ -283,33 +256,6 @@ struct DirectorySearchRow: View {
}
}
struct NameSearchRow: View {
@EnvironmentObject var theme: AppTheme
var result: NameSearchResult
var body: some View {
HStack(spacing: 8) {
ProfileImage(imageStr: result.image, icon: Image(systemName: result.iconName), size: 42)
VStack(alignment: .leading, spacing: 2) {
if let profileName = result.profileName {
(Text(profileName).fontWeight(.bold) + Text(verbatim: " (\(result.simplexName))").foregroundColor(theme.colors.secondary))
.lineLimit(1)
} else {
Text(result.simplexName).fontWeight(.bold).lineLimit(1)
}
if let descr = result.descr, !descr.isEmpty {
Text(descr)
.foregroundColor(theme.colors.secondary)
.lineLimit(2)
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
struct DirectoryPageInChatsRow: View {
@EnvironmentObject var theme: AppTheme
@@ -319,7 +265,7 @@ struct DirectoryPageInChatsRow: View {
}
}
struct DirectoryShowMoreRow: View {
struct DirectoryLoadMoreRow: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var onlineSearch: OnlineSearchModel
@@ -327,11 +273,22 @@ struct DirectoryShowMoreRow: View {
Button {
Task { await onlineSearch.loadMore() }
} label: {
Text("Show more")
.foregroundColor(onlineSearch.loading ? theme.colors.secondary : theme.colors.primary)
HStack(spacing: 4) {
Image(systemName: "plus.magnifyingglass")
Text(loadMoreText)
}
.foregroundColor(onlineSearch.loading ? theme.colors.secondary : theme.colors.primary)
}
.disabled(onlineSearch.loading)
}
private var loadMoreText: String {
if let n = onlineSearch.moreCount, n > 0 {
String.localizedStringWithFormat(NSLocalizedString("Load more · %d left", comment: "directory search results"), n)
} else {
NSLocalizedString("Load more", comment: "directory search results")
}
}
}
struct DirectoryRetryRow: View {
@@ -342,14 +299,48 @@ struct DirectoryRetryRow: View {
Button {
Task { await onlineSearch.retry() }
} label: {
Text("Search failed, tap to retry")
.foregroundColor(theme.colors.primary)
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
Text("Search failed, tap to retry")
}
.foregroundColor(theme.colors.primary)
}
}
}
struct DirectoryResultMenu: ViewModifier {
@EnvironmentObject var theme: AppTheme
var result: DirectoryResult
var imageSize: CGFloat
var onOpen: () -> Void
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content.contextMenu {
Button(action: onOpen) {
Label("Open", systemImage: "message")
}
} preview: {
DirectorySearchRow(entry: result.entry, imageSize: imageSize, state: result.state, showsOpen: false)
.padding()
.frame(width: UIScreen.main.bounds.width - 32)
.background(theme.colors.background)
.environmentObject(theme)
}
} else {
content
}
}
}
// a pending join keeps the sheet or alert the connection flow shows for it
func openDirectoryResult(_ result: DirectoryResult, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) {
let prepareGroup: PrepareGroupMode = if case .joining = result.state { .confirm } else { .open }
planAndConnect(result.entry.link, theme: theme, dismiss: dismiss, cleanup: cleanup, prepareGroup: prepareGroup)
}
func confirmDirectorySearch(_ search: @escaping () -> Void) {
if UserDefaults.standard.bool(forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN) {
if UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_DIRECTORY_SEARCH) {
search()
} else {
showDirectorySearchAlert(onSearch: search)
@@ -358,20 +349,15 @@ func confirmDirectorySearch(_ search: @escaping () -> Void) {
private 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. If it is a SimpleX name, it will also be sent to your server that resolves names.\n\nNo contact is created and your profile is not sent.", comment: "alert message"),
NSLocalizedString("Enable Directory search?", comment: "alert title"),
message: NSLocalizedString("Search text is sent to SimpleX Directory, each search separately and without your profile, so it can't build your search history.", 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"),
title: NSLocalizedString("Enable", comment: "alert action"),
style: .default,
handler: { _ in
UserDefaults.standard.set(true, forKey: DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN)
UserDefaults.standard.set(true, forKey: DEFAULT_PRIVACY_DIRECTORY_SEARCH)
onSearch()
}
)
@@ -93,10 +93,10 @@ struct NewChatSheet: View {
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
connectNameCandidate: $connectNameCandidate,
onSearch: runOnlineSearch
onSearch: runDirectorySearch
)
.frame(maxWidth: .infinity)
if let candidate = connectNameCandidate, onlineSearch.names.isEmpty {
if let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
@@ -105,7 +105,7 @@ struct NewChatSheet: View {
dismiss: true
)
}
if !searchTrimmed.isEmpty, !searchShowingSimplexLink {
if !searchTrimmed.isEmpty, !searchShowingSimplexLink, !onlineSearch.showResults {
SearchInDirectoryRow(searchFocussed: $searchFocussed, onSearch: runDirectorySearch)
}
}
@@ -182,7 +182,7 @@ struct NewChatSheet: View {
showDeletedChatIcon: true,
showsEmptyState: !onlineSearch.searching && rows.isEmpty
)
onlineSearchSections(rows)
onlineSearchSection(rows)
}
.onChange(of: searchText) { _ in
onlineSearch.reset()
@@ -192,49 +192,30 @@ struct NewChatSheet: View {
}
}
@ViewBuilder private func onlineSearchSections(_ rows: OnlineSearchRows) -> some View {
if !rows.names.isEmpty {
@ViewBuilder private func onlineSearchSection(_ rows: OnlineSearchRows) -> some View {
if !rows.isEmpty {
Section {
ForEach(rows.names) { result in
NameSearchRow(result: result)
.onTapGesture {
searchFocussed = false
planAndConnect(result.target, theme: theme, dismiss: true, cleanup: nil)
}
ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
}
} header: {
onlineSearchHeader("Names")
}
}
if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil {
Section {
ForEach(rows.directory) { result in
if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) {
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
} else {
DirectorySearchRow(entry: result.entry)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: true, cleanup: nil)
}
}
ForEach(rows.results) { result in
DirectorySearchRow(entry: result.entry, imageSize: 30, state: result.state)
.modifier(DirectoryResultMenu(result: result, imageSize: 30, onOpen: { openResult(result) }))
.onTapGesture { openResult(result) }
}
if rows.pageInChats { DirectoryPageInChatsRow() }
switch rows.footer {
case .retry: DirectoryRetryRow(onlineSearch: onlineSearch)
case .showMore: DirectoryShowMoreRow(onlineSearch: onlineSearch)
case .loadMore: DirectoryLoadMoreRow(onlineSearch: onlineSearch)
case nil: EmptyView()
}
} header: {
onlineSearchHeader("Directory")
}
}
}
private func onlineSearchHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.textCase(.uppercase)
.foregroundColor(theme.colors.secondary)
private func openResult(_ result: DirectoryResult) {
searchFocussed = false
openDirectoryResult(result, theme: theme, dismiss: true, cleanup: nil)
}
private var searchTrimmed: String {
@@ -247,13 +228,6 @@ struct NewChatSheet: View {
confirmDirectorySearch { Task { await onlineSearch.searchDirectory(text) } }
}
private func runOnlineSearch() {
let text = searchTrimmed
guard !text.isEmpty, !searchShowingSimplexLink else { return }
let name = connectNameCandidate
confirmDirectorySearch { Task { await onlineSearch.search(text, name: name) } }
}
private func contactListChatPredicate(_ chat: Chat, _ withSearch: Bool) -> Bool {
switch chat.chatInfo {
case .contactRequest: true
+30 -13
View File
@@ -1334,6 +1334,10 @@ private func showOpenKnownGroupAlert(
)
}
enum PrepareGroupMode {
case confirm, open
}
// Spec: spec/client/navigation.md#planAndConnect
func planAndConnect(
_ shortOrFullLink: String,
@@ -1342,7 +1346,8 @@ func planAndConnect(
dismiss: Bool,
cleanup: (() -> Void)? = nil,
filterKnownContact: ((Contact) -> Void)? = nil,
filterKnownGroup: ((GroupInfo) -> Void)? = nil
filterKnownGroup: ((GroupInfo) -> Void)? = nil,
prepareGroup: PrepareGroupMode = .confirm
) {
switch strConnectTarget(shortOrFullLink) {
case let .link(_, linkType, _):
@@ -1543,18 +1548,30 @@ func planAndConnect(
if let groupSLinkData = groupSLinkData_ {
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
await MainActor.run {
showPrepareGroupAlert(
connectionLink: connectionLink,
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
ownerVerification: ownerVerification,
verifiedDomain: planSimplexName?.nameDomain,
connectOtherButton: connectOtherButton,
connectOtherLink: connectOtherLink,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
)
switch prepareGroup {
case .confirm:
showPrepareGroupAlert(
connectionLink: connectionLink,
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
ownerVerification: ownerVerification,
verifiedDomain: planSimplexName?.nameDomain,
connectOtherButton: connectOtherButton,
connectOtherLink: connectOtherLink,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
)
case .open:
prepareAndOpenGroup(
connectionLink: connectionLink,
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
verifiedDomain: planSimplexName?.nameDomain,
dismiss: dismiss,
cleanup: cleanup
)
}
}
} else {
logger.debug("planAndConnect, .groupLink, .ok, no short link data")
@@ -17,6 +17,7 @@ struct PrivacySettings: View {
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = false
@AppStorage(DEFAULT_PRIVACY_DIRECTORY_SEARCH) private var directorySearch = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
@AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true
@@ -83,6 +84,9 @@ struct PrivacySettings: View {
privacyLinkPreviewsShowAlertGroupDefault.set(false)
}
}
settingsRow("magnifyingglass", color: theme.colors.secondary) {
Toggle("Directory search", isOn: $directorySearch)
}
settingsRow("link", color: theme.colors.secondary) {
Toggle("Remove link tracking", isOn: $privacySanitizeLinks)
}
@@ -41,6 +41,7 @@ let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
let DEFAULT_PRIVACY_DIRECTORY_SEARCH = "privacyDirectorySearch"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
@@ -59,7 +60,6 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN = "directorySearchAlertShown"
let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown"
let DEFAULT_SUPPORTER_BANNER_TAPPED = "supporterBannerTapped"
let DEFAULT_GET_STAKE_BANNER_TAPPED = "getStakeBannerTapped"
@@ -117,6 +117,7 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
DEFAULT_PRIVACY_DIRECTORY_SEARCH: false,
DEFAULT_EXPERIMENTAL_CALLS: false,
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
DEFAULT_DEVELOPER_TOOLS: false,
@@ -126,7 +127,6 @@ let appDefaults: [String: Any] = [
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN: false,
DEFAULT_SUPPORTER_BANNER_SHOWN: false,
DEFAULT_SUPPORTER_BANNER_TAPPED: false,
DEFAULT_GET_STAKE_BANNER_TAPPED: false,
@@ -163,7 +163,6 @@ let hintDefaults = [
DEFAULT_LA_NOTICE_SHOWN,
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN,
DEFAULT_SUPPORTER_BANNER_SHOWN,
DEFAULT_SUPPORTER_BANNER_TAPPED,
DEFAULT_GET_STAKE_BANNER_TAPPED,