ui: show "connect to name" button starting with 5 characters; ios: show button in New chat view search (#7318)

* ui: show "connect to name" button starting with 5 characters

* show button in new chat view search
This commit is contained in:
spaced4ndy
2026-07-28 12:03:42 +01:00
committed by GitHub
parent a58cd51c49
commit 7c931d08ea
3 changed files with 91 additions and 55 deletions
@@ -649,7 +649,13 @@ struct ChatListSearchBar: View {
// 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 {
connectByNameRow(candidate)
ConnectByNameRow(
name: candidate,
searchText: $searchText,
connectNameCandidate: $connectNameCandidate,
searchFocussed: $searchFocussed,
dismiss: false
)
} else {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
}
@@ -688,7 +694,13 @@ struct ChatListSearchBar: View {
}
}
if !oneHandUI, let candidate = connectNameCandidate {
connectByNameRow(candidate)
ConnectByNameRow(
name: candidate,
searchText: $searchText,
connectNameCandidate: $connectNameCandidate,
searchFocussed: $searchFocussed,
dismiss: false
)
}
}
.onChange(of: searchFocussed) { sf in
@@ -770,33 +782,6 @@ struct ChatListSearchBar: View {
}
}
// Row shown in place of the list tags when the search text is a SimpleX name. The @ icon marks a
// contact name, the tag icon a channel/other name; tapping hides the keyboard, connects online, and
// clears the field.
private func connectByNameRow(_ name: String) -> some View {
HStack(spacing: 4) {
Image(systemName: name.hasPrefix("@") ? "at" : "number")
.foregroundColor(theme.colors.primary)
Text(String.localizedStringWithFormat(NSLocalizedString("Connect to %@", comment: "new chat action"), name))
.foregroundColor(theme.colors.primary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
searchFocussed = false
planAndConnect(
name,
theme: theme,
dismiss: false,
cleanup: {
searchText = ""
connectNameCandidate = nil
}
)
}
}
private func connect(_ link: String) {
planAndConnect(
link,
@@ -812,11 +797,47 @@ struct ChatListSearchBar: View {
}
}
// Row shown when the search text is a SimpleX name in place of the list tags in the chat list, below
// the search field in the new chat sheet. The @ icon marks a contact name, the tag icon a channel/other
// name; tapping hides the keyboard, connects online, and clears the field.
struct ConnectByNameRow: View {
@EnvironmentObject var theme: AppTheme
var name: String
@Binding var searchText: String
@Binding var connectNameCandidate: String?
@FocusState.Binding var searchFocussed: Bool
var dismiss: Bool
var body: some View {
HStack(spacing: 4) {
Image(systemName: name.hasPrefix("@") ? "at" : "number")
.foregroundColor(theme.colors.primary)
Text(String.localizedStringWithFormat(NSLocalizedString("Connect to %@", comment: "new chat action"), name))
.foregroundColor(theme.colors.primary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
searchFocussed = false
planAndConnect(
name,
theme: theme,
dismiss: dismiss,
cleanup: {
searchText = ""
connectNameCandidate = nil
}
)
}
}
}
// Default top-level part used to complete a bare name typed in the search field (search field only;
// the message parser and the wire format are unchanged).
private let DEFAULT_NAME_TLD = "testing"
// Shortest name that offers the button, so it is discoverable but does not flash on a single letter.
private let MIN_NAME_LENGTH = 2
// Shortest name that offers the button, so it is discoverable but does not flash on short prefixes.
private let MIN_NAME_LENGTH = 5
private func isNameLabel(_ s: String) -> Bool {
s.count >= 1 && s.count <= 63 && s.range(of: "^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$", options: .regularExpression) != nil
@@ -41,6 +41,8 @@ struct NewChatSheet: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
// when the search text is a SimpleX name, the string to connect to (with @/# preserved); nil otherwise
@State private var connectNameCandidate: String? = nil
@State private var alert: SomeAlert?
// Sheet height management
@@ -81,15 +83,25 @@ struct NewChatSheet: View {
private func viewBody(_ showArchive: Bool) -> some View {
List {
HStack {
VStack(spacing: 12) {
ContactsListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
connectNameCandidate: $connectNameCandidate
)
.frame(maxWidth: .infinity)
if let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
connectNameCandidate: $connectNameCandidate,
searchFocussed: $searchFocussed,
dismiss: true
)
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
@@ -327,6 +339,7 @@ struct ContactsListSearchBar: View {
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var connectNameCandidate: String?
@State private var ignoreSearchTextChange = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@@ -381,33 +394,32 @@ struct ContactsListSearchBar: View {
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) {
let s = t.trimmingCharacters(in: .whitespaces)
switch strConnectTarget(s) {
case let .link(text, _, linkText):
searchFocussed = false
ignoreSearchTextChange = true
searchText = linkText
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connectNameCandidate = nil
connect(text)
case let .name(text, _):
searchFocussed = false
planAndConnect(
text,
theme: theme,
dismiss: true,
cleanup: {
searchText = ""
searchFocussed = false
default:
// A name is resolved only when its "Connect to " row is tapped, not on every keystroke.
// The simplex-name filter is chat-list only: this contacts/deleted view is a scoped
// subset, so a resolved chat id (channel, business, unlisted or active-only contact)
// may not be present in it.
let candidate = nameSearchCandidate(s)
connectNameCandidate = candidate
if candidate == nil {
if t != "" {
searchFocussed = true
} else {
connectProgressManager.cancelConnectProgress()
}
)
case .none:
if t != "" {
searchFocussed = true
} else {
connectProgressManager.cancelConnectProgress()
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
@@ -449,7 +461,9 @@ struct DeletedChats: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
// deleted contacts are not connected to by name, so this candidate only stops per-keystroke resolution
@State private var connectNameCandidate: String? = nil
var body: some View {
List {
ContactsListSearchBar(
@@ -457,7 +471,8 @@ struct DeletedChats: View {
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
connectNameCandidate: $connectNameCandidate
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
@@ -1039,8 +1039,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
// Default top-level part used to complete a bare name typed in the search field (search field only;
// the message parser and the wire format are unchanged).
private const val DEFAULT_NAME_TLD = "testing"
// Shortest name that offers the button, so it is discoverable but does not flash on a single letter.
private const val MIN_NAME_LENGTH = 2
// Shortest name that offers the button, so it is discoverable but does not flash on short prefixes.
private const val MIN_NAME_LENGTH = 5
// Wait this long after the last keystroke before the local name search runs.
internal const val NAME_SEARCH_DEBOUNCE_MS = 300L