ios+mac: an inline chip says when a chat-list filter is hiding chats (#555)
With the funnel narrowing the list, the only cue was the filled toolbar
glyph — a chat could vanish (e.g. a DM going offline under "Online only")
with nothing saying why. Now an active filter renders a quiet inline chip
at the top of the chat list on both platforms: plain words for what the
filter is doing ("Online only", "Hiding apps", "Online only · hiding
bots & apps") plus an ✕ that restores the defaults.
The copy derivation (chatFilterSummary) lives in LChatCore's Presence.swift
beside the filter rules it describes, shared by both chips, with unit tests.
On iOS the chip occupies top safe-area space under the nav bar (frosted, the
list scrolls behind like the bar itself); on the Mac it scrolls with the
rows, filled like the search box above it. Filter semantics and the filled
funnel glyph are unchanged.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+129
−0
ios/LChat/Views/ThreadListView.swift+49 −0ios/LChatCore/Sources/LChatCore/Presence.swift+22 −0ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift+16 −0ios/LChatMac/NativeChatView.swift+42 −0
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 65fdb92..86761e1 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -79,6 +79,13 @@ struct ThreadListView: View { |
| searchResults |
| } |
| } |
| + // An active funnel says so in words, in the layout, right where the |
| + // missing rows would be — the toolbar's filled glyph alone proved too |
| + // quiet a cue when a chat silently vanished from the list. Occupies |
| + // safe-area space (never floats over rows), with a one-tap way back. |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + if filtering { filterChip } |
| + } |
| .searchable(text: $search, prompt: "Search chats") |
| // A fresh member lands straight in Eleven's welcome conversation (the |
| // web's maybeStartWelcome, natively): open the welcome app-thread on |
| @@ -307,6 +314,48 @@ struct ThreadListView: View { |
| .padding() |
| } |
| + // The quiet capsule under the nav bar while the funnel narrows: what the |
| + // filter is doing in plain words (chatFilterSummary, shared with the Mac) |
| + // plus the ✕ that restores the defaults. Secondary tones on purpose — it |
| + // informs, the list stays the subject. |
| + private var filterChip: some View { |
| + HStack(spacing: 5) { |
| + Image(systemName: "line.3.horizontal.decrease") |
| + .font(.caption2.weight(.semibold)) |
| + Text(chatFilterSummary(onlineOnly: filterOnline, people: filterPeople, |
| + bots: filterBots, apps: filterApps) ?? "") |
| + .font(.footnote) |
| + .lineLimit(1) |
| + Button { resetFilters() } label: { |
| + Image(systemName: "xmark.circle.fill") |
| + .font(.footnote) |
| + .foregroundStyle(.tertiary) |
| + // Grow the tap target without growing the capsule. |
| + .padding(6) |
| + .contentShape(Rectangle()) |
| + } |
| + .padding(-6) |
| + .accessibilityLabel("Show all chats") |
| + } |
| + .foregroundStyle(.secondary) |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 6) |
| + .background(Capsule().fill(.fill.tertiary)) |
| + .padding(.vertical, 6) |
| + .frame(maxWidth: .infinity) |
| + // The list scrolls behind this strip (it ignores the top safe area), |
| + // so frost it like the bar above rather than letting rows collide. |
| + .background(.bar) |
| + } |
| + |
| + // Back to the funnel's defaults: every category on, online-only off. |
| + private func resetFilters() { |
| + filterOnline = false |
| + filterPeople = true |
| + filterBots = true |
| + filterApps = true |
| + } |
| + |
| // The funnel emptied the list. Name the online case — it's the one people |
| // actually hit — and fall back to a generic line for the category toggles. |
| private var filteredEmpty: some View { |
diff --git a/ios/LChatCore/Sources/LChatCore/Presence.swift b/ios/LChatCore/Sources/LChatCore/Presence.swift| index cabbd8f..2b99c24 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Presence.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Presence.swift |
| @@ -53,6 +53,28 @@ public func chatCategory(isApp: Bool, others: [MemberPresence]) -> ChatFilterCat |
| return .people |
| } |
| +/// The inline chip's words for an active funnel filter — the plain-language |
| +/// answer to "why is a chat missing from my list?". Reads like a person would |
| +/// say it ("Online only", "Hiding bots & apps", "Online only · hiding apps"), |
| +/// not a flag dump; nil when nothing is filtering. Shared so the iOS and Mac |
| +/// chips can't drift on copy. |
| +public func chatFilterSummary(onlineOnly: Bool, people: Bool, bots: Bool, apps: Bool) -> String? { |
| + var hidden: [String] = [] |
| + if !people { hidden.append("people") } |
| + if !bots { hidden.append("bots") } |
| + if !apps { hidden.append("apps") } |
| + // "a, b & c" — the & keeps the last pair conversational at any length. |
| + let list = hidden.count > 1 |
| + ? hidden.dropLast().joined(separator: ", ") + " & " + hidden[hidden.count - 1] |
| + : hidden.first |
| + switch (onlineOnly, list) { |
| + case (true, nil): return "Online only" |
| + case (true, let l?): return "Online only · hiding \(l)" |
| + case (false, let l?): return "Hiding \(l)" |
| + case (false, nil): return nil |
| + } |
| +} |
| + |
| /// The status line under a name: a coloured dot + the word, the native mirror |
| /// of the web's .presence-line (green online / yellow away / red offline). |
| /// Renders nothing when the state is unknown. Text font/colour follow the |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift b/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift| index 53dd4d2..d576896 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/PresenceTests.swift |
| @@ -57,6 +57,22 @@ final class PresenceTests: XCTestCase { |
| XCTAssertEqual(chatCategory(isApp: false, others: []), .people) // your own Notes |
| } |
| + // The inline filter chip's copy — one shared derivation, so iOS and Mac say |
| + // the same words for the same flags. |
| + func testChatFilterSummary() { |
| + XCTAssertNil(chatFilterSummary(onlineOnly: false, people: true, bots: true, apps: true)) |
| + XCTAssertEqual(chatFilterSummary(onlineOnly: true, people: true, bots: true, apps: true), |
| + "Online only") |
| + XCTAssertEqual(chatFilterSummary(onlineOnly: false, people: true, bots: true, apps: false), |
| + "Hiding apps") |
| + XCTAssertEqual(chatFilterSummary(onlineOnly: false, people: true, bots: false, apps: false), |
| + "Hiding bots & apps") |
| + XCTAssertEqual(chatFilterSummary(onlineOnly: false, people: false, bots: false, apps: false), |
| + "Hiding people, bots & apps") |
| + XCTAssertEqual(chatFilterSummary(onlineOnly: true, people: true, bots: false, apps: false), |
| + "Online only · hiding bots & apps") |
| + } |
| + |
| func testPresenceText() { |
| XCTAssertEqual(presenceText("online"), "Online") |
| XCTAssertEqual(presenceText("away"), "Away") |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 5e48baf..fb5cbff 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1059,6 +1059,11 @@ struct NativeChatView: View { |
| private var threadList: some View { |
| ScrollView { |
| LazyVStack(spacing: 1) { |
| + // An active funnel says so in words at the top of the list — |
| + // the filled glyph alone proved too quiet a cue when a chat |
| + // silently vanished. Inline (scrolls with the rows), with a |
| + // one-click way back to everything. |
| + if filtering { filterChip } |
| ForEach(visibleThreads) { thread in |
| threadRow(thread) |
| } |
| @@ -1081,6 +1086,43 @@ struct NativeChatView: View { |
| } |
| } |
| + // The quiet capsule above the rows while the funnel narrows: what the |
| + // filter is doing in plain words (chatFilterSummary, shared with iOS) |
| + // plus the ✕ that restores the defaults. Tones and fill match the search |
| + // box above it — it informs, the list stays the subject. |
| + private var filterChip: some View { |
| + HStack(spacing: 5) { |
| + Image(systemName: "line.3.horizontal.decrease") |
| + .font(.system(size: 10, weight: .semibold)) |
| + Text(chatFilterSummary(onlineOnly: filterOnline, people: filterPeople, |
| + bots: filterBots, apps: filterApps) ?? "") |
| + .font(.system(size: 11)) |
| + .lineLimit(1) |
| + Button { resetFilters() } label: { |
| + Image(systemName: "xmark.circle.fill") |
| + .font(.system(size: 11)) |
| + .foregroundStyle(.tertiary) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .help("Show all chats") |
| + } |
| + .foregroundStyle(.secondary) |
| + .padding(.horizontal, 10) |
| + .padding(.vertical, 4) |
| + .background(Capsule().fill(.primary.opacity(0.06))) |
| + .frame(maxWidth: .infinity) |
| + .padding(.bottom, 5) |
| + } |
| + |
| + // Back to the funnel's defaults: every category on, online-only off. |
| + private func resetFilters() { |
| + filterOnline = false |
| + filterPeople = true |
| + filterBots = true |
| + filterApps = true |
| + } |
| + |
| private func threadRow(_ t: ChatThread) -> some View { |
| Button { |
| selectedID = t.id |