eleven / messenger Public

ios+mac: search filter in the New-chat pickers, matching the web (#804)

The web's New-chat sheet filters the roster as you type; the native
pickers just showed the whole list. Add the same filter to both:

- iOS NewChatView: an always-visible search field over People/Helpers,
  action rows step aside mid-query, "No matches." empty state, Return
  opens the top match. NewGroupView's member picker filters too.
- Mac NewChatPopover: a Search field above the list filtering people
  and (single-chat mode) app rows + Admin by title, Enter opens the
  top match / creates the group — the web's submit-on-Enter.
- LChatCore: ThreadStore.rosterMatches mirrors the web's rosterMatches
  (case-insensitive substring on label OR nickname). The picker rows now
  also *display* the nickname-resolved name via store.displayName —
  they were the last member lists still showing raw labels post-#577.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4c785c01c983d9a6d5d3100ac7a276c47d86d895 parent df6b478
3 files changed, +159 −63
  • ios/LChat/Views/NewChatView.swift +95 −56
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +12 −0
  • ios/LChatMac/NewChatPopover.swift +52 −7
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
index 9a0a68a..0de7c2c 100644
--- a/ios/LChat/Views/NewChatView.swift
+++ b/ios/LChat/Views/NewChatView.swift
@@ -13,71 +13,81 @@ struct NewChatView: View {
@State private var busyWith: String? // user id of the DM being created ("admin" = the Admin row)
@State private var error: String?
+ @State private var search = ""
+ private var query: String { search.trimmingCharacters(in: .whitespaces) }
private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } }
- private var people: [RosterEntry] { others.filter { !$0.isAgent } }
+ private var people: [RosterEntry] { store.rosterMatches(others.filter { !$0.isAgent }, query) }
// Bots off empties the Helpers section (matching the web picker) — existing
// bot DMs stay in the sidebar; only starting new ones hides.
- private var helpers: [RosterEntry] { store.botsEnabled ? others.filter { $0.isAgent } : [] }
+ private var helpers: [RosterEntry] {
+ store.botsEnabled ? store.rosterMatches(others.filter { $0.isAgent }, query) : []
+ }
var body: some View {
NavigationStack {
List {
- Section {
- NavigationLink {
- NewGroupView(store: store, onCreated: finish)
- } label: {
- Label {
- Text("New group").font(.body.weight(.medium))
- } icon: {
- Image(systemName: "person.3.fill")
- .foregroundStyle(Color.accentColor)
+ // The action rows step aside while a query is live — search
+ // results are people, not actions (clear the field to get
+ // them back). The web keeps its equivalents in the sheet
+ // header, so they never compete with matches there either.
+ if query.isEmpty {
+ Section {
+ NavigationLink {
+ NewGroupView(store: store, onCreated: finish)
+ } label: {
+ Label {
+ Text("New group").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "person.3.fill")
+ .foregroundStyle(Color.accentColor)
+ }
}
- }
- // Bring someone new onto the server (if the admin allows
- // member invites — the server says no otherwise).
- NavigationLink {
- InviteSomeoneView(store: store) { _ in dismiss() }
- } label: {
- Label {
- Text("Invite someone").font(.body.weight(.medium))
- } icon: {
- Image(systemName: "person.badge.plus")
- .foregroundStyle(Color.accentColor)
+ // Bring someone new onto the server (if the admin allows
+ // member invites — the server says no otherwise).
+ NavigationLink {
+ InviteSomeoneView(store: store) { _ in dismiss() }
+ } label: {
+ Label {
+ Text("Invite someone").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "person.badge.plus")
+ .foregroundStyle(Color.accentColor)
+ }
}
- }
- // The community directory: open groups anyone can join.
- NavigationLink {
- DiscoverGroupsView(store: store, onOpen: finish)
- } label: {
- Label {
- Text("Discover groups").font(.body.weight(.medium))
- } icon: {
- Image(systemName: "safari")
- .foregroundStyle(Color.accentColor)
+ // The community directory: open groups anyone can join.
+ NavigationLink {
+ DiscoverGroupsView(store: store, onOpen: finish)
+ } label: {
+ Label {
+ Text("Discover groups").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "safari")
+ .foregroundStyle(Color.accentColor)
+ }
}
- }
- // The Admin app, admins only (the roster carries our role; the
- // server re-checks it) — same open-the-app-thread action as the
- // web picker's Apps row, so /admin isn't the only way in.
- if store.isAdmin {
- Button { openAdmin() } label: {
- HStack {
- Label {
- Text("Admin").font(.body.weight(.medium))
- } icon: {
- Image(systemName: "gearshape")
- .foregroundStyle(Color.accentColor)
+ // The Admin app, admins only (the roster carries our role; the
+ // server re-checks it) — same open-the-app-thread action as the
+ // web picker's Apps row, so /admin isn't the only way in.
+ if store.isAdmin {
+ Button { openAdmin() } label: {
+ HStack {
+ Label {
+ Text("Admin").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "gearshape")
+ .foregroundStyle(Color.accentColor)
+ }
+ Spacer()
+ if busyWith == "admin" { ProgressView() }
}
- Spacer()
- if busyWith == "admin" { ProgressView() }
+ .contentShape(Rectangle())
}
- .contentShape(Rectangle())
+ .buttonStyle(.plain)
+ .disabled(busyWith != nil)
+ .accessibilityLabel("Admin")
+ .accessibilityHint("Opens this space's admin panel")
}
- .buttonStyle(.plain)
- .disabled(busyWith != nil)
- .accessibilityLabel("Admin")
- .accessibilityHint("Opens this space's admin panel")
}
}
@@ -86,6 +96,11 @@ struct NewChatView: View {
Text("No one else has joined this chat yet.")
.foregroundStyle(.secondary)
}
+ } else if !query.isEmpty && people.isEmpty && helpers.isEmpty {
+ Section {
+ Text("No matches.")
+ .foregroundStyle(.secondary)
+ }
}
if !people.isEmpty {
Section("People") {
@@ -103,6 +118,17 @@ struct NewChatView: View {
}
.navigationTitle("New chat")
.navigationBarTitleDisplayMode(.inline)
+ // Always-visible, like the web picker's Search field — hiding it
+ // behind a pull-down would read as "no way to filter" all over again.
+ .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always),
+ prompt: "Search")
+ // Return opens the top match, same as Enter on the web — but only
+ // mid-query: a stray Search tap on an empty field shouldn't start
+ // a DM with whoever happens to sort first.
+ .onSubmit(of: .search) {
+ guard !query.isEmpty, busyWith == nil else { return }
+ if let first = people.first ?? helpers.first { startDM(first) }
+ }
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
}
@@ -112,8 +138,11 @@ struct NewChatView: View {
private func personRow(_ person: RosterEntry) -> some View {
Button { startDM(person) } label: {
HStack(spacing: 12) {
- Avatar(name: person.label, size: 36, source: store.avatarSource(for: person))
- Text(person.label).foregroundStyle(.primary)
+ // Nickname-resolved, like every other member list — the filter
+ // matches this name, so the row must show it too.
+ Avatar(name: store.displayName(for: person), size: 36,
+ source: store.avatarSource(for: person))
+ Text(store.displayName(for: person)).foregroundStyle(.primary)
if person.isAgent { BotBadge() }
Spacer()
if busyWith == person.user_id { ProgressView() }
@@ -165,11 +194,15 @@ struct NewGroupView: View {
@State private var openGroup = false
@State private var busy = false
@State private var error: String?
+ @State private var search = ""
+ private var query: String { search.trimmingCharacters(in: .whitespaces) }
// Bots off drops agents from the member picker, same as the web's
// add-to-group sheet (adding a bot to a group is starting new bot use).
private var others: [RosterEntry] {
- store.roster.filter { $0.user_id != store.myUserId && (store.botsEnabled || !$0.isAgent) }
+ store.rosterMatches(store.roster.filter {
+ $0.user_id != store.myUserId && (store.botsEnabled || !$0.isAgent)
+ }, query)
}
var body: some View {
@@ -189,11 +222,15 @@ struct NewGroupView: View {
}
}
Section(header: Text(selected.isEmpty ? "Add people" : "\(selected.count) selected")) {
+ if others.isEmpty && !query.isEmpty {
+ Text("No matches.").foregroundStyle(.secondary)
+ }
ForEach(others, id: \.user_id) { person in
Button { toggle(person.user_id) } label: {
HStack(spacing: 12) {
- Avatar(name: person.label, size: 32, source: store.avatarSource(for: person))
- Text(person.label).foregroundStyle(.primary)
+ Avatar(name: store.displayName(for: person), size: 32,
+ source: store.avatarSource(for: person))
+ Text(store.displayName(for: person)).foregroundStyle(.primary)
if person.isAgent { BotBadge() }
Spacer()
if selected.contains(person.user_id) {
@@ -247,6 +284,8 @@ struct NewGroupView: View {
}
.navigationTitle("New group")
.navigationBarTitleDisplayMode(.inline)
+ .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always),
+ prompt: "Search")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if busy { ProgressView() }
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 62d739d..092aabe 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -506,6 +506,18 @@ public final class ThreadStore: ObservableObject {
return name.isEmpty ? entry.label : name
}
+ /// The web picker's rosterMatches, natively: case-insensitive substring on
+ /// the published label OR my nickname for them (displayName), so the filter
+ /// finds people by whichever name the picker row shows. An empty query
+ /// passes everyone through — the pickers filter unconditionally.
+ public func rosterMatches(_ entries: [RosterEntry], _ query: String) -> [RosterEntry] {
+ guard !query.isEmpty else { return entries }
+ return entries.filter {
+ $0.label.localizedCaseInsensitiveContains(query)
+ || displayName(for: $0).localizedCaseInsensitiveContains(query)
+ }
+ }
+
/// The unacknowledged rename for a member, if any (see Nicknames.renameNotice).
public func renameNotice(for uid: String) -> (from: String, to: String)? {
Nicknames.renameNotice(nicknames[uid], label: rosterLabels[uid])
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift
index 8465e83..1d35131 100644
--- a/ios/LChatMac/NewChatPopover.swift
+++ b/ios/LChatMac/NewChatPopover.swift
@@ -31,12 +31,32 @@ struct NewChatPopover: View {
@Environment(\.colorScheme) private var scheme
private var accent: Color { ChatTheme.classic.accent.resolved(scheme) }
+ @State private var search = ""
+ private var query: String { search.trimmingCharacters(in: .whitespaces) }
+
// Bots off drops agents from the picker (matching the web sheet) — existing
// bot DMs stay in the sidebar; only starting new ones hides.
private var others: [RosterEntry] {
- store.roster.filter {
+ store.rosterMatches(store.roster.filter {
$0.user_id != store.myUserId && !$0.label.isEmpty && (store.botsEnabled || !$0.isAgent)
- }
+ }, query)
+ }
+
+ // The web picker searches apps by title too (matchingApps/adminMatches) —
+ // typing "to" should surface To-do just like it does there.
+ private var visibleApps: [(id: String, title: String, glyph: String)] {
+ query.isEmpty ? ThreadStore.personalApps
+ : ThreadStore.personalApps.filter { $0.title.localizedCaseInsensitiveContains(query) }
+ }
+ private var adminVisible: Bool {
+ store.isAdmin && (query.isEmpty || "Admin".localizedCaseInsensitiveContains(query))
+ }
+ // "No matches." mirrors the web sheet: only when nothing at all survives the
+ // query. Group mode searches people only (shared apps are picked, not found).
+ private var noMatches: Bool {
+ guard !query.isEmpty, others.isEmpty else { return false }
+ if groupMode { return true }
+ return !(store.appsEnabled && !visibleApps.isEmpty) && !adminVisible
}
var body: some View {
@@ -62,6 +82,7 @@ struct NewChatPopover: View {
groupMode.toggle()
selected = []
selectedApps = []
+ search = ""
}
.buttonStyle(.link)
.font(.system(size: 12))
@@ -84,12 +105,33 @@ struct NewChatPopover: View {
.toggleStyle(.checkbox)
}
}
+ // The web sheet's Search field, right above the list. Enter opens
+ // the top match (or, in group mode, creates the group) — same as
+ // the web's submit-on-Enter.
+ TextField("Search", text: $search)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 13))
+ .onSubmit {
+ guard !busy else { return }
+ if groupMode {
+ if !selected.isEmpty || openGroup { create(Array(selected), name: groupName) }
+ } else if let first = others.first {
+ create([first.user_id], name: nil)
+ }
+ }
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 1) {
ForEach(others, id: \.user_id) { person in
personRow(person)
}
+ if noMatches {
+ Text("No matches.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ }
// Bring someone new onto the server (the iOS picker's row;
// the server says no if the admin turned member invites off).
if !groupMode {
@@ -102,14 +144,14 @@ struct NewChatPopover: View {
// lands as a groupapps.add in the fresh log). Both vanish
// when the admin turned apps off — except the Admin row,
// which is how an admin would switch apps back on.
- if !groupMode && (store.appsEnabled || store.isAdmin) {
+ if !groupMode && ((store.appsEnabled && !visibleApps.isEmpty) || adminVisible) {
Text("APPS")
.font(.system(size: 10, weight: .semibold))
.kerning(0.5)
.foregroundStyle(.tertiary)
.padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0))
if store.appsEnabled {
- ForEach(ThreadStore.personalApps, id: \.id) { appEntry in
+ ForEach(visibleApps, id: \.id) { appEntry in
appRow(appEntry)
}
}
@@ -118,7 +160,7 @@ struct NewChatPopover: View {
// way in beyond /admin. Same open action, popover closes
// into the thread via onCreated like every app row.
// Deliberately exempt from the apps gate.
- if store.isAdmin {
+ if adminVisible {
appRow(("admin", "Admin", "🛠️"))
.accessibilityLabel("Admin")
.accessibilityHint("Opens this space's admin panel")
@@ -260,6 +302,7 @@ struct NewChatPopover: View {
copied = false
error = nil
busy = false
+ search = "" // a reopened popover starts unfiltered, like the web sheet
}
private func createInvite() {
@@ -293,9 +336,11 @@ struct NewChatPopover: View {
}
} label: {
HStack(spacing: 8) {
- NativeChatView.ThreadAvatar(title: person.label,
+ // Nickname-resolved, like every other member list — the filter
+ // matches this name, so the row must show it too.
+ NativeChatView.ThreadAvatar(title: store.displayName(for: person),
source: store.avatarSource(for: person), size: 26)
- Text(person.label)
+ Text(store.displayName(for: person))
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
if person.isAgent {