| 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() } |