Skip to content

Grouped channels endpoint (v5)#4076

Draft
laevandus wants to merge 54 commits into
developfrom
grouped-channels-v5
Draft

Grouped channels endpoint (v5)#4076
laevandus wants to merge 54 commits into
developfrom
grouped-channels-v5

Conversation

@laevandus
Copy link
Copy Markdown
Contributor

@laevandus laevandus commented Apr 28, 2026

🔗 Issue Links

Resolves https://linear.app/stream/issue/IOS-1635/support-for-grouped-channels-endpoint.

🎯 Goal

  • Add ChatClient.queryGroupedChannels(limit:presence:watch:) (callback + async throws overloads) that fetches grouped channel groups from POST /channels/grouped and returns them as a public ChannelGroup model (group key, channel ids, per-group unread channel count).
  • Add ChatClient.makeChannelList(with: groupKey) — state-layer factory that creates a ChannelList for a single group, with pagination, observation, and offline-sync wired in.
  • Add CurrentChatUser.groupedUnreadCount: [String: Int]? — per-group unread-channel counts, decoded from grouped_unread_channels on relevant WS events (NotificationMessageNew, NotificationMarkRead, NotificationMarkUnread, MessageNew, ChannelTruncated, NotificationChannelDeleted) via the new HasGroupedUnreadCount protocol and persisted on CurrentUserDTO.

📝 Summary

Test on https://github.com/GetStream/GroupedChannelsSample.

This is the v5 port of the grouped-channels-endpoint work that originally landed on the v4 line. All public-API changes are additive; the legacy ChatChannelListController is intentionally not extended for grouped channels — group-based observation lives only in the state-layer ChannelList.

🛠 Implementation

  • New endpoint: POST /channels/grouped wired through ChannelEndpoints/EndpointPath with GroupedQueryChannelsRequestBody / GroupedQueryChannelsPayload / GroupedQueryChannelsGroupPayload.
  • State layer: ChannelList branches on query.groupKey — initial fetch and pagination go through queryGroupedChannels (the regular /channels path is skipped). Pagination cursors are persisted on ChannelListQueryDTO.next.
  • Sync: SyncGroupedChannelsOperation runs once during the offline-sync chain and issues a single shared queryGroupedChannels for all active group-based ChannelLists, populating their query DTOs so each list's subsequent reads hit the cache.
  • Linking: ChannelListLinker detects a channel's group via channel.extraData["group"] (GroupedChannelKey.extraData) and routes link/unlink decisions accordingly. The reserved "all" group key (GroupedChannelKey.all) is a union — every channel that has a non-empty group is linked to it.
  • Grouped unread count maintenance: ChannelListUpdater.link increments and unlink decrements CurrentUserDTO.groupedUnreadCount[groupKey] (floored at 0) when the channel actually has unread messages and the membership set actually changes. The "all" group is skipped — its count is driven by server-side WS events.
  • Page size sentinel: The grouped endpoint rejects limit > 10. Instead of hard-coding 10, ChannelListQuery(groupKey:) uses Pagination(pageSize: .unsetPageSize) (= -1), and Pagination.encode skips writing limit when negative — letting the backend pick its default.
  • Core Data: Two new optional attributes; lightweight migration, no model version bump.
    • ChannelListQueryDTO.next: String? — per-group pagination cursor.
    • CurrentUserDTO.unreadGroupedChannelsCounts: Data? — JSON-encoded [String: Int], exposed via the computed groupedUnreadCount accessor.
  • Concurrency: Adapted to Swift 6 strict concurrency on develop: Sendable conformance on the new payloads/models, @Sendable on completion handlers, nonisolated(unsafe) for mutated locals captured in database.write blocks, and explicit @Sendable on the inner closures inside SyncGroupedChannelsOperation to escape @MainActor inheritance from the queryGroupedChannels callback (otherwise crashes at _swift_task_checkIsolatedSwift on .utility-qos).

🎨 Showcase

Add relevant screenshots and/or videos/gifs to easily see what this PR changes, if applicable.

Before After
img img

🧪 Manual Testing Notes

Use the GroupedChannelsSample app. Verify:

  • Listing groups via queryGroupedChannels returns groups with correct per-group unread channel counts.
  • Creating a ChannelList for a groupKey shows that group's channels and paginates through them.
  • Sending/reading messages updates groupedUnreadCount on CurrentChatUser via WS events.
  • Linking/unlinking a channel into/out of a non-"all" group adjusts the local groupedUnreadCount immediately; the "all" group reflects the server's value.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

Summary by CodeRabbit

  • New Features

    • Fetch channels grouped by backend "group" with cursor pagination; new client API to query grouped channels and factory to create/observe a grouped ChannelList.
    • Current user exposes per-group unread channel counts; relevant sync, websocket events, and notifications now carry grouped unread counts.
    • Added ChannelGroup model and group-key aware query/pagination behavior.
  • Tests

    • Added comprehensive tests for grouped queries, payload decoding, pagination, persistence, sync, and unread-count handling.

Review Change Stack

martinmitrevski and others added 24 commits April 28, 2026 09:39
…sent (avoids manually keeping the message count up to date which is part of the count_messages app setting)
…ersist using it

 - Add internal ChannelListQuery.groupKey and queryHash (groupKey ?? filter.filterHash);
    ChannelListQueryDTO now uses this stable identity so date-bearing filters from the
    grouped endpoint don't churn filterHash every second.
  - Rename channelListQuery(filterHash:) -> channelListQuery(_:) since every caller had
    the full ChannelListQuery in scope anyway.
  - Rename GroupedChannelsGroup.group -> groupKey for naming symmetry.
  - prefill(group:) sets query.groupKey before worker.prefill; drop the redundant
    ChatChannelListController.prefilledGroupKey in favor of query.groupKey as the single
    source of truth.
  - SyncRepository routes prefilled controllers through queryGroupedChannels and
    SyncGroupedChannelsOperation forwards each group back to the matching controller's
    prefill(group:) using query.groupKey.
@laevandus laevandus requested a review from a team as a code owner April 28, 2026 08:02
@laevandus laevandus marked this pull request as draft April 28, 2026 08:02
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds grouped-channel querying and observation: endpoint and payloads, ChatClient API and factory, ChannelGroup model and per-group unread counts persisted in Core Data, state-layer group-aware pagination/linking, sync batching for grouped lists, event propagation for grouped unread counts, and extensive tests and mocks.

Changes

Grouped Channels Listing & Unread Counts

Layer / File(s) Summary
API endpoints & payload models
Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift, Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift, Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift, Tests/StreamChatTests/APIClient/Endpoints/*
Adds EndpointPath.groupedChannels, Endpoint.groupedChannels(request:), request/response payloads (GroupedQueryChannelsRequestBody, per-group request, GroupedQueryChannelsPayload, per-group payload) and tests validating encoding/decoding and endpoint building.
Models and query helpers
Sources/StreamChat/Models/ChannelGroup.swift, Sources/StreamChat/Models/CurrentUser.swift, Sources/StreamChat/Query/ChannelListQuery.swift, Sources/StreamChat/Query/Filter.swift, Sources/StreamChat/Query/Pagination.swift
Adds ChannelGroup model, CurrentChatUser.unreadChannelCountsByGroup, ChannelListQuery(groupKey:) and queryHash, Filter.empty, Int.unsetPageSize, and conditional Pagination encoding to omit limit when unset.
Database schema & DTOs
Sources/StreamChat/Database/StreamChatModel.xcdatamodel/contents, Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift, Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
Adds Core Data attributes (ChannelListQueryDTO.next/watch/presence, CurrentUserDTO.unreadGroupedChannelsCounts), DTO computed property for JSON decode/encode, and NSManagedObjectContext helpers to merge/adjust per-group unread counts and lookup/save queries by query.queryHash.
Channel DTO & DatabaseSession changes
Sources/StreamChat/Database/DTOs/ChannelDTO.swift, Sources/StreamChat/Database/DatabaseSession.swift
ChannelDTO save/delete and query matching updated to use query.queryHash; fetch batch size fallback for unset pageSize. DatabaseSession protocol added methods to merge/adjust unread counts by group and channelListQuery(_:) now accepts ChannelListQuery. DatabaseSession.saveEvent merges grouped unread counts when present.
State Layer & Pagination Management
Sources/StreamChat/StateLayer/ChannelList.swift, Sources/StreamChat/StateLayer/ChannelListState.swift, Sources/StreamChat/StateLayer/ChatClient+Factory.swift
ChannelList stores its query, branches loadChannels/loadMoreChannels on groupKey to use cursor-based per-group pagination from persisted state and manages hasLoadedAllPreviousChannels. ChannelListState.query becomes mutable. Adds ChatClient.makeChannelList(with:) factory.
ChannelListUpdater & pagination state
Sources/StreamChat/Workers/ChannelListUpdater.swift
Adds GroupedQueryPaginationState, paginationState(for:), async queryGroupedChannels overload, persistence of per-group next/watch/presence, and uses channelListQuery(query) lookups.
Sync operations & repository changes
Sources/StreamChat/Repositories/SyncOperations.swift, Sources/StreamChat/Repositories/SyncRepository.swift
RefreshChannelListOperation skips grouped lists; introduces SyncGroupedChannelsOperation that batches grouped lists into a single grouped /channels request; SyncRepository splits grouped vs non-grouped refresh handling.
Channel List Linker with Group Logic
Sources/StreamChat/Workers/ChannelListLinker.swift
Centralized linking/unlinking via handle(channel:allowedActions:) and linkingAction(for:). Group-based membership derives from channel extraData["group"]; filter-based uses existing filter/auto-filtering logic.
Events, payloads & middleware
Sources/StreamChat/WebSocketClient/Events/Event.swift, Sources/StreamChat/WebSocketClient/Events/EventPayload.swift, Sources/StreamChat/WebSocketClient/Events/*, Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
Adds HasUnreadChannelCountsByGroup protocol, EventPayload support for grouped_unread_channels, threads unreadChannelCountsByGroup into message/notification/channel events, and middleware adjusts per-group unread counts when channels move groups.
Mocks, spies & test infra updates
TestTools/StreamChatTestTools/*
Mocks and spies updated to @Sendable completion signatures, @Atomic grouped-query spy state, DatabaseSession_Mock delegation fixes, ChannelList_Mock tracks refresh calls, and test helpers updated to use state.query for DTO lookups.
Unit & integration tests
Tests/StreamChatTests/*
Extensive tests added/updated for endpoint/payloads, ChatClient.queryGroupedChannels and factory, per-group pagination persistence/behavior, DB merge semantics for unread counts, SyncRepository grouped behavior, ChannelListUpdater grouped flows, and middleware event handling.
Misc: helpers & formatting
Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift, Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift, CHANGELOG.md
Minor formatting/whitespace adjustments, changelog entry documenting grouped-channel APIs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested labels

🟢 QAed

Suggested reviewers

  • martinmitrevski

🐰 Through grouped channels we hop,
Unread counts at the top!
Cursors whisper, groups align,
Sync and state keep everything fine.
Hooray — a rabbit's tiny sprint, refined.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch grouped-channels-v5

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChatUI XCSize

Object Diff (bytes)
ChatChannelListRouter.o -44

Copy link
Copy Markdown
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good in general! Need to test a bit, also left some comments, let me know what you think.

Comment thread Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift Outdated
import Foundation

/// A grouped channels response returned by `ChatClient.queryGroupedChannels`.
public struct GroupedChannels: Sendable {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we do final classes instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For public data types we still do struct, so for consistency reasons I think it is better to follow the same pattern

public let groupKey: String

/// The channels that belong to this group.
public let channels: [ChatChannel]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels strange to return this as part of the current user, wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, now we should drop these and the way for reading channels is through ChannelList

Comment thread Sources/StreamChat/Models/GroupedChannels.swift Outdated
Comment thread Sources/StreamChat/Models/UnreadCount.swift Outdated
let groupedChannels = try await channelListUpdater.queryGroupedChannels(
groupPagination: .init(groupKey: groupKey, next: pagination.cursor),
limit: pagination.pageSize,
watch: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why's this hardcoded to true? Afaik, it should be false, and we rely on membership events. Or this changed in the meantime?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it everywhere back to false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume backend confirmed this, right?

extension ChannelListState {
final class Observer {
private let channelListObserver: StateLayerDatabaseObserver<ListResult, ChatChannel, ChannelDTO>
@MainActor final class Observer {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be on the main actor?

worker.unlink(channel: channel, with: query) { _ in
completion?()
}
guard exists else { return }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're not calling completion here, while we did that before. Is that expected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is expected, it was a bit off before where same event could do both unlink and link (only one action would actually do something)

.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if let currentGroupKey, !currentGroupKey.isEmpty {
return groupKey == currentGroupKey || groupKey == "all" ? .link : .unlink
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this check is not ideal, are we sure it's always going to be called "all"? But not sure if there's better way, we don't have any clue about the filter.


if clientConfig.isChannelAutomaticFilteringEnabled {
// When auto-filtering is enabled the channel will appear or not automatically if the
// query matches the DB Predicate. So here we default to saying it always belong to the current query.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I fully understand - does that mean that in this case, we delegate the decision to the db predicate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some extensive documentation above

Comment thread Sources/StreamChat/Controllers/DatabaseObserver/BackgroundDatabaseObserver.swift Outdated
@laevandus laevandus marked this pull request as ready for review May 21, 2026 12:31
@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

SDK Performance

target metric benchmark branch performance status
MessageList Hitches total duration 10 ms 10.01 ms -0.1% 🔽 🟡
Duration 2.6 s 2.54 s 2.31% 🔼 🟢
Hitch time ratio 4 ms per s 3.95 ms per s 1.25% 🔼 🟢
Frame rate 75 fps 79.21 fps 5.61% 🔼 🟢
Number of hitches 1 1.0 0.0% 🟰 🟢

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift (1)

39-57: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset all newly tracked call counters in cleanUp().

Line 54-Line 57 clears related spy state but leaves startWatchingChannels_callCount, link_callCount, and unlink_callCount unchanged, which can leak state across tests.

Suggested patch
     func cleanUp() {
         update_queries.removeAll()
         update_completion = nil
         update_completion_result = nil

         fetch_queries.removeAll()
         fetch_completion = nil

         queryGroupedChannels_callCount = 0
         queryGroupedChannels_paginations.removeAll()
         queryGroupedChannels_watchValues.removeAll()
         queryGroupedChannels_presenceValues.removeAll()
         queryGroupedChannels_result = nil
         markAllRead_completion = nil

+        startWatchingChannels_callCount = 0
         startWatchingChannels_cids.removeAll()
         startWatchingChannels_completion = nil
         startWatchingChannels_completion_success = false
+        link_callCount = 0
+        unlink_callCount = 0
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift`
around lines 39 - 57, The cleanUp() method in ChannelListUpdater_Spy doesn't
reset three tracked call counters which can leak state between tests; update
cleanUp() to also reset startWatchingChannels_callCount, link_callCount, and
unlink_callCount to their initial values (likely 0) so all spy counters are
cleared when cleanUp() runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 6-12: Update the CHANGELOG.md upcoming StreamChat entry to
reference the correct symbol name: replace the incorrect
`unreadChannelCountsByGroup` with `groupedUnreadCount` (e.g., mention
`groupedUnreadCount` on `CurrentChatUser` and any related API like
`ChatClient.queryGroupedChannels` / `ChatClient.makeChannelList`). Also add the
required `StreamChatUI` and `StreamChatCommonUI` subsections under the `#
Upcoming` header with brief placeholders or notes so all three components
(StreamChat, StreamChatUI, StreamChatCommonUI) are present as per guidelines.

In `@Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift`:
- Around line 70-85: channelListQuery(_:) and saveQuery(query:) currently use
query.queryHash directly to load and persist ChannelListQueryDTO.filterHash,
causing collisions between normal and grouped queries; update both functions to
normalize the stored lookup key by distinguishing grouped queries (e.g., if
query.groupKey != nil then use a derived key like "group:\(query.groupKey)" or a
dedicated groupedHash) and ensure channelListQuery and saveQuery use the same
transformed key when fetching/inserting (reference
ChannelListQueryDTO.filterHash, query.queryHash, and query.groupKey). This keeps
grouped queries namespaced (or stored separately) so
channels/next/watch/presence state won't be mixed.

In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift`:
- Around line 149-157: In saveCurrentUserUnreadChannelCountsByGroup, treat a
missing CurrentUserDTO as a cache miss rather than a hard failure: after calling
invalidateCurrentUserCache(), replace the guard that throws
ClientError.CurrentUserDoesNotExist() with a safe optional check so that if
currentUser is nil the method simply returns (no-op) instead of throwing; when
dto exists assign dto.unreadChannelCountsByGroup as before. This avoids aborting
writes from callers like queryGroupedChannels and event persistence when the
CurrentUserDTO hasn't been materialized yet.

In `@Sources/StreamChat/StateLayer/ChannelList.swift`:
- Around line 117-119: The code passes the sentinel page-size (.unsetPageSize)
through resolved into Pagination and the completion check; update the logic in
the load-more path (around resolved, loadChannels(with: Pagination(...)), and
setHasLoadedAllPreviousChannels(...)) to detect the sentinel and replace it with
a concrete fallback page size before constructing Pagination and before
comparing channels.count < resolved (e.g., compute let effectivePageSize =
(resolved == .unsetPageSize) ? <defaultPageSize> : resolved and use
effectivePageSize for Pagination(pageSize: ...) and for the completion check).

In `@Sources/StreamChat/Workers/ChannelListLinker.swift`:
- Around line 179-190: The grouping logic in linkingAction(for channel:
ChatChannel) treats a missing/empty
channel.extraData[GroupedChannelKey.extraData] as .none, which prevents
unlinking previously linked channels after a ChannelUpdatedEvent; change that
final fallback to return .unlink so empty or missing group values will remove
the channel from the grouped query. Keep the existing comparisons against
query.groupKey and GroupedChannelKey.all; only replace the "return .none" branch
with "return .unlink" in the linkingAction(for:) implementation.

In `@Sources/StreamChat/Workers/ChannelListUpdater.swift`:
- Around line 227-253: During a full grouped refresh (when request.groups == nil
/ isInitialFetch true) the current write only updates groups present in
payload.groups and leaves persisted ChannelListQueryDTOs for groups removed
server-side; modify the database.write(converting:) block in
ChannelListUpdater.swift to detect a full grouped refresh and remove or clear
any saved grouped queries not present in payload.groups: after building/updating
queryDTO for each payload.groups entry, enumerate existing saved group queries
via the session (e.g., saved ChannelListQuery/ChannelListQueryDTO entries),
compute the set difference with payload.groups.keys, and for each missing group
either delete the query DTO or clear its channels, next cursor, watch/presence
flags and unread counts so makeChannelList(with:) no longer surfaces stale data.
Ensure this logic runs only for full refreshes (isInitialFetch / request.groups
== nil) and reference queryDTO, saveQuery(query: ChannelListQuery), and
makeChannelList(with:) when locating where to apply the cleanup.

---

Outside diff comments:
In `@TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift`:
- Around line 39-57: The cleanUp() method in ChannelListUpdater_Spy doesn't
reset three tracked call counters which can leak state between tests; update
cleanUp() to also reset startWatchingChannels_callCount, link_callCount, and
unlink_callCount to their initial values (likely 0) so all spy counters are
cleared when cleanUp() runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 512bae13-b8d0-4d2a-a10a-b5ef743014b7

📥 Commits

Reviewing files that changed from the base of the PR and between 7b31d54 and 24abf5b.

📒 Files selected for processing (47)
  • CHANGELOG.md
  • Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
  • Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift
  • Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
  • Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift
  • Sources/StreamChat/ChatClient.swift
  • Sources/StreamChat/Database/DTOs/ChannelDTO.swift
  • Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
  • Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
  • Sources/StreamChat/Database/DatabaseSession.swift
  • Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
  • Sources/StreamChat/Models/ChannelGroup.swift
  • Sources/StreamChat/Models/CurrentUser.swift
  • Sources/StreamChat/Query/ChannelListQuery.swift
  • Sources/StreamChat/Query/Filter+predicate.swift
  • Sources/StreamChat/Query/Filter.swift
  • Sources/StreamChat/Query/Pagination.swift
  • Sources/StreamChat/Repositories/SyncOperations.swift
  • Sources/StreamChat/Repositories/SyncRepository.swift
  • Sources/StreamChat/StateLayer/ChannelList.swift
  • Sources/StreamChat/StateLayer/ChannelListState.swift
  • Sources/StreamChat/StateLayer/ChatClient+Factory.swift
  • Sources/StreamChat/StateLayer/DatabaseObserver/StateLayerDatabaseObserver.swift
  • Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift
  • Sources/StreamChat/WebSocketClient/Events/ChannelEvents.swift
  • Sources/StreamChat/WebSocketClient/Events/Event.swift
  • Sources/StreamChat/WebSocketClient/Events/EventPayload.swift
  • Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift
  • Sources/StreamChat/WebSocketClient/Events/NotificationEvents.swift
  • Sources/StreamChat/Workers/ChannelListLinker.swift
  • Sources/StreamChat/Workers/ChannelListUpdater.swift
  • TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/ChatChannelListController_Mock.swift
  • TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift
  • TestTools/StreamChatTestTools/Mocks/StreamChat/State/ChannelList_Mock.swift
  • TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift
  • Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift
  • Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift
  • Tests/StreamChatTests/ChatClient_Tests.swift
  • Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift
  • Tests/StreamChatTests/Database/DatabaseSession_Tests.swift
  • Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift
  • Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift
  • Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift
  • Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
  • Tests/StreamChatTests/WebSocketClient/Events/MessageEvents_Tests.swift
  • Tests/StreamChatTests/WebSocketClient/Events/NotificationEvents_Tests.swift
  • Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift

Comment thread CHANGELOG.md
Comment on lines +6 to 12
## StreamChat
### ✅ Added
- Add `ChatClient.queryGroupedChannels(limit:presence:watch:)` to fetch grouped channels with per-group unread counts [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
- Add `ChatClient.makeChannelList(with:)` overload for observing a single grouped channels group in the state layer [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
- Add `unreadChannelCountsByGroup` to `CurrentChatUser`, observable for changes via `ConnectedUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
### 🔄 Changed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the Upcoming changelog entry before merge.

This block documents unreadChannelCountsByGroup, but the feature in this PR is groupedUnreadCount, so the release notes will point users to a non-existent symbol. It also omits the required StreamChatUI and StreamChatCommonUI subsections under # Upcoming.

As per coding guidelines, Include separate subsections in CHANGELOG.md for StreamChat, StreamChatUI, and StreamChatCommonUI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 6 - 12, Update the CHANGELOG.md upcoming
StreamChat entry to reference the correct symbol name: replace the incorrect
`unreadChannelCountsByGroup` with `groupedUnreadCount` (e.g., mention
`groupedUnreadCount` on `CurrentChatUser` and any related API like
`ChatClient.queryGroupedChannels` / `ChatClient.makeChannelList`). Also add the
required `StreamChatUI` and `StreamChatCommonUI` subsections under the `#
Upcoming` header with brief placeholders or notes so all three components
(StreamChat, StreamChatUI, StreamChatCommonUI) are present as per guidelines.

Comment on lines +70 to 85
func channelListQuery(_ query: ChannelListQuery) -> ChannelListQueryDTO? {
ChannelListQueryDTO.load(filterHash: query.queryHash, context: self)
}

func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO {
if let existingDTO = channelListQuery(filterHash: query.filter.filterHash) {
if let existingDTO = channelListQuery(query) {
return existingDTO
}

let request = ChannelListQueryDTO.fetchRequest(
keyPath: #keyPath(ChannelListQueryDTO.filterHash),
equalTo: query.filter.filterHash
equalTo: query.queryHash
)
let newDTO = NSEntityDescription.insertNewObject(into: self, for: request)
newDTO.filterHash = query.filter.filterHash
newDTO.filterHash = query.queryHash

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Namespace grouped query ids before persisting them.

These lookups now store both regular list queries and grouped list queries in the same unique filterHash column via query.queryHash. Since grouped queries use the raw groupKey, one collision aliases the DTO and mixes channels, next, watch, and presence state between unrelated lists. Prefix grouped hashes (for example, group:<key>) or persist them in a separate field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift` around lines 70 -
85, channelListQuery(_:) and saveQuery(query:) currently use query.queryHash
directly to load and persist ChannelListQueryDTO.filterHash, causing collisions
between normal and grouped queries; update both functions to normalize the
stored lookup key by distinguishing grouped queries (e.g., if query.groupKey !=
nil then use a derived key like "group:\(query.groupKey)" or a dedicated
groupedHash) and ensure channelListQuery and saveQuery use the same transformed
key when fetching/inserting (reference ChannelListQueryDTO.filterHash,
query.queryHash, and query.groupKey). This keeps grouped queries namespaced (or
stored separately) so channels/next/watch/presence state won't be mixed.

Comment on lines +149 to +157
func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
invalidateCurrentUserCache()

guard let dto = currentUser else {
throw ClientError.CurrentUserDoesNotExist()
}

dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat missing CurrentUserDTO as a cache miss here.

queryGroupedChannels and event persistence call this after a successful network response. If the client has not materialized CurrentUserDTO yet, throwing CurrentUserDoesNotExist aborts the whole write even though only the grouped unread-count cache is missing. This should be a guarded no-op instead of a hard failure.

Possible localized fix
 func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
     invalidateCurrentUserCache()
 
-    guard let dto = currentUser else {
-        throw ClientError.CurrentUserDoesNotExist()
-    }
+    guard let dto = currentUser else { return }
 
     dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
invalidateCurrentUserCache()
guard let dto = currentUser else {
throw ClientError.CurrentUserDoesNotExist()
}
dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
func saveCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
invalidateCurrentUserCache()
guard let dto = currentUser else { return }
dto.unreadChannelCountsByGroup = unreadChannelCountsByGroup
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift` around lines 149 -
157, In saveCurrentUserUnreadChannelCountsByGroup, treat a missing
CurrentUserDTO as a cache miss rather than a hard failure: after calling
invalidateCurrentUserCache(), replace the guard that throws
ClientError.CurrentUserDoesNotExist() with a safe optional check so that if
currentUser is nil the method simply returns (no-op) instead of throwing; when
dto exists assign dto.unreadChannelCountsByGroup as before. This avoids aborting
writes from callers like queryGroupedChannels and event persistence when the
CurrentUserDTO hasn't been materialized yet.

Comment thread Sources/StreamChat/StateLayer/ChannelList.swift
Comment on lines +179 to +190
private func linkingAction(for channel: ChatChannel) -> LinkingAction {
if let groupKey = query.groupKey {
// Group-based queries have no filter predicate; membership is decided entirely from
// the channel's "group" extra-data value. The "all" group is a catch-all that links
// every channel carrying any non-empty group value.
let currentGroupKey = channel.extraData[GroupedChannelKey.extraData]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if let currentGroupKey, !currentGroupKey.isEmpty {
return groupKey == currentGroupKey || groupKey == GroupedChannelKey.all ? .link : .unlink
}
return .none
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unlink grouped channels when the group value is cleared.

For grouped queries, missing or empty "group" currently resolves to .none. After a ChannelUpdatedEvent, that leaves previously linked channels stuck in grouped lists because handle never reaches unlinkChannel.

Suggested fix
         if let groupKey = query.groupKey {
             // Group-based queries have no filter predicate; membership is decided entirely from
             // the channel's "group" extra-data value. The "all" group is a catch-all that links
             // every channel carrying any non-empty group value.
             let currentGroupKey = channel.extraData[GroupedChannelKey.extraData]?.stringValue?
                 .trimmingCharacters(in: .whitespacesAndNewlines)
                 .lowercased()
             if let currentGroupKey, !currentGroupKey.isEmpty {
                 return groupKey == currentGroupKey || groupKey == GroupedChannelKey.all ? .link : .unlink
             }
-            return .none
+            return .unlink
         } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Workers/ChannelListLinker.swift` around lines 179 - 190,
The grouping logic in linkingAction(for channel: ChatChannel) treats a
missing/empty channel.extraData[GroupedChannelKey.extraData] as .none, which
prevents unlinking previously linked channels after a ChannelUpdatedEvent;
change that final fallback to return .unlink so empty or missing group values
will remove the channel from the grouped query. Keep the existing comparisons
against query.groupKey and GroupedChannelKey.all; only replace the "return
.none" branch with "return .unlink" in the linkingAction(for:) implementation.

Comment on lines +227 to +253
database.write(converting: { session in
if isInitialFetch {
let unreadChannelCountsByGroup = payload.groups.mapValues(\.unreadChannels)
try session.saveCurrentUserUnreadChannelCountsByGroup(unreadChannelCountsByGroup)
}
var groups: [ChannelGroup] = []
for (groupKey, groupPayload) in payload.groups {
let queryDTO = session.saveQuery(query: ChannelListQuery(groupKey: groupKey))
if isInitialFetch || isFirstPageForSingleGroup {
queryDTO.channels.removeAll()
}
queryDTO.next = groupPayload.next
queryDTO.watch = watch
queryDTO.presence = presence
let channelIds = groupPayload.channels.compactMapLoggingError { channelPayload in
let dto = try session.saveChannel(payload: channelPayload)
queryDTO.channels.insert(dto)
return channelPayload.channel.cid
}
groups.append(ChannelGroup(
groupKey: groupKey,
channelIds: channelIds,
unreadChannels: groupPayload.unreadChannels,
next: groupPayload.next
))
}
return groups
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Full grouped refresh needs to clean up groups missing from the response.

When request.groups == nil, this is the authoritative snapshot of configured groups, but the write only updates groups that are still present in payload.groups. Any previously persisted group that has been removed server-side keeps its old ChannelListQueryDTO, linked channels, and next cursor, so makeChannelList(with:) can keep surfacing stale data after a config change. Please delete or clear grouped-query DTOs that are absent from a full refresh.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Workers/ChannelListUpdater.swift` around lines 227 - 253,
During a full grouped refresh (when request.groups == nil / isInitialFetch true)
the current write only updates groups present in payload.groups and leaves
persisted ChannelListQueryDTOs for groups removed server-side; modify the
database.write(converting:) block in ChannelListUpdater.swift to detect a full
grouped refresh and remove or clear any saved grouped queries not present in
payload.groups: after building/updating queryDTO for each payload.groups entry,
enumerate existing saved group queries via the session (e.g., saved
ChannelListQuery/ChannelListQueryDTO entries), compute the set difference with
payload.groups.keys, and for each missing group either delete the query DTO or
clear its channels, next cursor, watch/presence flags and unread counts so
makeChannelList(with:) no longer surfaces stale data. Ensure this logic runs
only for full refreshes (isInitialFetch / request.groups == nil) and reference
queryDTO, saveQuery(query: ChannelListQuery), and makeChannelList(with:) when
locating where to apply the cleanup.

Copy link
Copy Markdown
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, let's test this extensively

extension Int {
/// Sentinel signalling "no client-side page size" — the request omits `limit` and the backend uses its default.
/// `0` matches `NSFetchRequest.fetchLimit`'s "no limit" semantics, so it safely passes through to Core Data unchanged.
static let unsetPageSize = 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you check also with the backend that 0 will return their default?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is more future proof to use -1 here since some APIs can interpret it as return nothing (although the pagination encoding below was filtering it out).

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
Sources/StreamChat/Workers/ChannelListUpdater.swift (1)

231-252: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Full grouped refresh should clear persisted groups missing from the response.

When a fetch-all response is authoritative, retaining previously saved group query DTOs that are absent in the payload can surface stale channels/cursors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Workers/ChannelListUpdater.swift` around lines 231 - 252,
The grouped refresh loop currently only updates/creates queries found in
payload.groups but doesn't remove previously persisted group queries that are
missing when the fetch is a fresh/full refresh; update the ChannelListUpdater
logic to, when wasFreshFetch (i.e., request.groups?[groupKey]?.next == nil) for
the fetch, compute the set of existing saved group queries (use
session.saveQuery(query: ChannelListQuery(...)) or a session query-listing
method) and delete any query DTOs whose groupKey is not present in
payload.groups; ensure you call the session's delete/remove method for those
query DTOs (the same persistence/session API used to saveChannel/saveQuery) so
stale group queries/channels/cursors are cleared.
Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift (1)

149-154: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat missing current user as a cache miss instead of a write failure.

At Line 152, throwing when currentUser is missing makes grouped unread-count persistence fail even though this data is cache-like. A guarded no-op is safer here.

Suggested fix
 func mergeCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
     invalidateCurrentUserCache()

-    guard let dto = currentUser else {
-        throw ClientError.CurrentUserDoesNotExist()
-    }
+    guard let dto = currentUser else { return }

     dto.unreadChannelCountsByGroup = (dto.unreadChannelCountsByGroup ?? [:]).merging(unreadChannelCountsByGroup) { _, new in new }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift` around lines 149 -
154, The mergeCurrentUserUnreadChannelCountsByGroup method currently treats a
missing currentUser as an error and throws
ClientError.CurrentUserDoesNotExist(); instead, change this to a no-op cache
miss: after calling invalidateCurrentUserCache(), if currentUser is nil simply
return (do not throw) so the function exits quietly when currentUser is absent;
update the guard in mergeCurrentUserUnreadChannelCountsByGroup that references
currentUser to an early return rather than throwing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/StreamChat/Repositories/SyncOperations.swift`:
- Around line 145-151: The code currently derives watch/presence from a single
group's pagination state (via paginationState(for:)) which is nondeterministic
when groups differ; instead, fetch paginationState(for:) for all groupKeys and
compute deterministic flags (e.g., watch = any(state.watch == true), presence =
any(state.presence == true)) and then pass those aggregated booleans into
channelListUpdater.queryGroupedChannels(groups:..., watch:..., presence:...);
update the logic around groupKeys, paginationState(for:), and the
queryGroupedChannels call to use these OR-aggregated flags rather than a single
group's values.

---

Duplicate comments:
In `@Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift`:
- Around line 149-154: The mergeCurrentUserUnreadChannelCountsByGroup method
currently treats a missing currentUser as an error and throws
ClientError.CurrentUserDoesNotExist(); instead, change this to a no-op cache
miss: after calling invalidateCurrentUserCache(), if currentUser is nil simply
return (do not throw) so the function exits quietly when currentUser is absent;
update the guard in mergeCurrentUserUnreadChannelCountsByGroup that references
currentUser to an early return rather than throwing.

In `@Sources/StreamChat/Workers/ChannelListUpdater.swift`:
- Around line 231-252: The grouped refresh loop currently only updates/creates
queries found in payload.groups but doesn't remove previously persisted group
queries that are missing when the fetch is a fresh/full refresh; update the
ChannelListUpdater logic to, when wasFreshFetch (i.e.,
request.groups?[groupKey]?.next == nil) for the fetch, compute the set of
existing saved group queries (use session.saveQuery(query:
ChannelListQuery(...)) or a session query-listing method) and delete any query
DTOs whose groupKey is not present in payload.groups; ensure you call the
session's delete/remove method for those query DTOs (the same
persistence/session API used to saveChannel/saveQuery) so stale group
queries/channels/cursors are cleared.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 80af788b-78ce-4ba8-beac-22fac01e6f4e

📥 Commits

Reviewing files that changed from the base of the PR and between 2a59ed5 and 3c33a56.

📒 Files selected for processing (15)
  • Sources/StreamChat/ChatClient.swift
  • Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
  • Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
  • Sources/StreamChat/Database/DatabaseSession.swift
  • Sources/StreamChat/Models/ChannelGroup.swift
  • Sources/StreamChat/Repositories/SyncOperations.swift
  • Sources/StreamChat/StateLayer/ChannelList.swift
  • Sources/StreamChat/StateLayer/ChatClient+Factory.swift
  • Sources/StreamChat/Workers/ChannelListUpdater.swift
  • TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift
  • Tests/StreamChatTests/ChatClient_Tests.swift
  • Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift
  • Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift
  • Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware_Tests.swift
  • Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift

Comment on lines +145 to +151
let state = try await channelListUpdater.paginationState(for: sampleGroupKey)
let groups = Dictionary(uniqueKeysWithValues: groupKeys.map { ($0, GroupedQueryChannelsRequestGroup(limit: nil, next: nil)) })
let channelGroups = try await channelListUpdater.queryGroupedChannels(
groups: groups,
limit: nil,
watch: state.watch ?? false,
presence: state.presence ?? false
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid deriving watch/presence from an arbitrary group state.

At Line 145 and Line 150, using groupKeys.first makes behavior non-deterministic if grouped queries have divergent persisted flags. Derive flags deterministically from all group states (e.g., OR semantics) before issuing the shared refresh.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Repositories/SyncOperations.swift` around lines 145 - 151,
The code currently derives watch/presence from a single group's pagination state
(via paginationState(for:)) which is nondeterministic when groups differ;
instead, fetch paginationState(for:) for all groupKeys and compute deterministic
flags (e.g., watch = any(state.watch == true), presence = any(state.presence ==
true)) and then pass those aggregated booleans into
channelListUpdater.queryGroupedChannels(groups:..., watch:..., presence:...);
update the logic around groupKeys, paginationState(for:), and the
queryGroupedChannels call to use these OR-aggregated flags rather than a single
group's values.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Sources/StreamChat/Repositories/SyncOperations.swift (1)

141-156: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Aggregate watch/presence across all active grouped queries, not a single sampled group.

Using only sampleGroupKey can still produce incorrect flags when persisted grouped queries diverge. sorted().first is deterministic, but it may disable watch/presence needed by other active groups.

💡 Suggested fix
-            // All grouped lists share the same persisted flags (set together by the initial
-            // `queryGroupedChannels` call), so any one of them is a valid source. `sorted().first`
-            // keeps the choice deterministic across runs.
-            guard let channelListUpdater, let sampleGroupKey = groupKeys.sorted().first else {
+            guard let channelListUpdater, !groupKeys.isEmpty else {
                 done(.continue)
                 return
             }

             Task {
                 do {
-                    let state = try await channelListUpdater.paginationState(for: sampleGroupKey)
+                    var watch = false
+                    var presence = false
+                    for groupKey in groupKeys {
+                        let state = try await channelListUpdater.paginationState(for: groupKey)
+                        watch = watch || (state.watch ?? false)
+                        presence = presence || (state.presence ?? false)
+                    }
                     let groups = Dictionary(uniqueKeysWithValues: groupKeys.map { ($0, GroupedQueryChannelsRequestGroup(limit: nil, next: nil)) })
                     let channelGroups = try await channelListUpdater.queryGroupedChannels(
                         groups: groups,
                         limit: nil,
-                        watch: state.watch ?? false,
-                        presence: state.presence ?? false
+                        watch: watch,
+                        presence: presence
                     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/StreamChat/Repositories/SyncOperations.swift` around lines 141 - 156,
The code samples paginationState from a single sampleGroupKey which can miss
watch/presence enabled in other groups; update the logic in the block that uses
channelListUpdater, groupKeys, sampleGroupKey, paginationState and
queryGroupedChannels to fetch paginationState for all groupKeys (e.g. map
groupKeys -> await channelListUpdater.paginationState(for: key)), then compute
aggregated flags like watch = states.contains { $0.watch == true } and presence
= states.contains { $0.presence == true } and pass those aggregated booleans
into channelListUpdater.queryGroupedChannels instead of using
state.watch/state.presence from only sampleGroupKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@Sources/StreamChat/Repositories/SyncOperations.swift`:
- Around line 141-156: The code samples paginationState from a single
sampleGroupKey which can miss watch/presence enabled in other groups; update the
logic in the block that uses channelListUpdater, groupKeys, sampleGroupKey,
paginationState and queryGroupedChannels to fetch paginationState for all
groupKeys (e.g. map groupKeys -> await channelListUpdater.paginationState(for:
key)), then compute aggregated flags like watch = states.contains { $0.watch ==
true } and presence = states.contains { $0.presence == true } and pass those
aggregated booleans into channelListUpdater.queryGroupedChannels instead of
using state.watch/state.presence from only sampleGroupKey.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd391aea-7ea0-4744-a72a-e79d8656c151

📥 Commits

Reviewing files that changed from the base of the PR and between 3c33a56 and 6989801.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
  • Sources/StreamChat/Database/DatabaseSession.swift
  • Sources/StreamChat/Repositories/SyncOperations.swift
  • Tests/StreamChatTests/ChatClient_Tests.swift
  • Tests/StreamChatTests/Database/DTOs/CurrentUserDTO_Tests.swift
  • Tests/StreamChatTests/Database/DatabaseSession_Tests.swift
  • Tests/StreamChatTests/Repositories/SyncRepository_Tests.swift
  • Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

@github-actions
Copy link
Copy Markdown

Public Interface

+ public protocol HasUnreadChannelCountsByGroup: Event

+ public struct ChannelGroup: Sendable  
+ 
+   public let groupKey: String
+   public let unreadChannels: Int
+   public let channelIds: [ChannelId]



- public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount  
+ public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup  
+   public let unreadChannelCountsByGroup: [String: Int]?

- public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount  
+ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup  
+   public let unreadChannelCountsByGroup: [String: Int]?

 public class ChatClient: @unchecked Sendable  
-   public func uploadAttachment(localUrl: URL,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
+   @discardableResult public func queryGroupedChannels(groups: [String],limit: Int? = nil,presence: Bool = false,watch: Bool = false)async throws -> [ChannelGroup]
-   public func deleteAttachment(remoteUrl: URL,completion: @escaping @Sendable (Error?) -> Void)
+   public func uploadAttachment(localUrl: URL,progress: (@Sendable (Double) -> Void)?,completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void)
+   public func deleteAttachment(remoteUrl: URL,completion: @escaping @Sendable (Error?) -> Void)

- public final class NotificationMarkUnreadEvent: ChannelSpecificEvent  
+ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup  
-   public let unreadMessagesCount: Int
+   public let unreadChannelCountsByGroup: [String: Int]?
+   public let unreadMessagesCount: Int

- public final class ChannelTruncatedEvent: ChannelSpecificEvent  
+ public final class ChannelTruncatedEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup  
+   public let unreadChannelCountsByGroup: [String: Int]?

- public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount  
+ public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount, HasUnreadChannelCountsByGroup  
-   public let lastReadMessageId: MessageId?
+   public let unreadChannelCountsByGroup: [String: Int]?
-   public let createdAt: Date
+   public let lastReadMessageId: MessageId?
+   public let createdAt: Date

- public final class NotificationChannelDeletedEvent: ChannelSpecificEvent  
+ public final class NotificationChannelDeletedEvent: ChannelSpecificEvent, HasUnreadChannelCountsByGroup  
+   public let unreadChannelCountsByGroup: [String: Int]?

 public class CurrentChatUser: ChatUser, @unchecked Sendable  
-   public let isInvisible: Bool
+   public let unreadChannelCountsByGroup: [String: Int]?
-   public let privacySettings: UserPrivacySettings
+   public let isInvisible: Bool
-   public let pushPreference: PushPreference?
+   public let privacySettings: UserPrivacySettings
+   public let pushPreference: PushPreference?

 @MainActor public final class ChannelListState: ObservableObject  
-   public let query: ChannelListQuery
+   public private var query: ChannelListQuery

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

SDK Size

title develop branch diff status
StreamChat 6.85 MB 6.91 MB +62 KB 🟢
StreamChatUI 4.25 MB 4.25 MB 0 KB 🟢
StreamChatCommonUI 0.8 MB 0.8 MB 0 KB 🟢

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChat XCSize

Object Diff (bytes)
ChannelListPayload.o +17304
SyncOperations.o +8524
ChannelList.o +7872
ChannelListUpdater.o +6605
CurrentUserDTO.o +2608
Show 42 more objects
Object Diff (bytes)
ChannelReadUpdaterMiddleware.o +2590
RequestEncoder.o +2498
StreamCDNStorage.o +1476
ChannelListLinker.o +1426
EndpointPath.o +1395
ChannelUpdater.o -1264
MessageDTO.o +872
ChatClient.o +804
AttachmentDownloader.o +797
LivestreamChannelController.o +796
OfflineRequestsRepository.o +760
SyncRepository.o +734
ChatClient+Factory.o +724
ChannelGroup.o +683
ChannelDTO.o +616
ChannelEvents.o +608
NotificationEvents.o +592
AnyAttachmentPayload.o +550
AppSettings.o +544
MessageUpdater.o +404
APIClient.o +370
PushDevice.o -356
MessagePayloads.o -292
LocationPayloads.o -268
MessageEvents.o +260
EventPayload.o +242
DraftPayloads.o -240
ChannelListQueryDTO.o +213
CurrentUser.o +203
ChannelListState.o +188
Event.o +168
MessageSender.o +148
UnknownChannelEvent.o +124
ChatRemoteNotificationHandler.o +112
ChannelListQuery.o +109
Chat.o -76
MessageModerationDetailsPayload.o +64
ChatClientFactory.o +56
ConnectedUser.o -52
CurrentUserUpdater.o -48
StreamCore.o -44
ChannelMemberListUpdater.o +44

@sonarqubecloud
Copy link
Copy Markdown

watch: true is required to receive channel.updated events for the returned channels.
Comment on lines +284 to +286
if let lastMessageAt = payload.lastMessageAt {
dto.lastMessageAt = lastMessageAt.bridgeDate
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laevandus This can be dangerous, AFAIK, in some scenarios, we do want lastMessageAt to be reset to nil. This most likely will break some scenarios. Why was this unwrapping added?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is from the initial batch of changes when Martin started with it. I think I should remove it because the initial implementation had logic around it.

Comment on lines +502 to +508
request.fetchLimit = query.pagination.pageSize == .unsetPageSize ? 0 : query.pagination.pageSize
// For grouped queries `pageSize` is `.unsetPageSize`, which would disable batching.
// Fall back to the standard channels page size to keep memory bounded as the linked set
// grows across pagination.
request.fetchBatchSize = query.pagination.pageSize == .unsetPageSize
? .channelsPageSize
: query.pagination.pageSize
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? Not very clear from the docs 🤔

Comment on lines +47 to +48
/// `ChatChannel.extraData` field that carries the channel's group membership.
static let extraData = "group"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit weird, shouldn't it be static let group = "group"?

Comment on lines +83 to +85
/// The stable identity used for locating / linking the corresponding `ChannelListQueryDTO`.
/// Uses `groupKey` when set (stable across date-bearing filters from the grouped endpoint);
/// otherwise falls back to `filter.filterHash`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are date-bearing filters? Maybe we can select a bit easier wording here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should remove it, this is part of the second iteration of this feature and it is not relevant with what we have now. At some point there were queries with Date() in it.

Comment on lines +45 to +59
/// Fetches the first page of channels from the server and registers the list for reconnect sync.
///
/// - For filter-based lists, ``ChannelListState/channels`` is reset to the first page returned
/// by the channels endpoint.
/// - For group-based lists (created via ``ChatClient/makeChannelList(with:)-(String)``), the
/// first page is fetched from the grouped endpoint with no cursor; the request inherits the
/// `watch` / `presence` flags persisted by the most recent
/// ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)`` call for the group.
///
/// Subsequent pages are loaded via ``loadMoreChannels(limit:)``.
///
/// - Important: Loaded channels in ``ChannelListState/channels`` are reset.
/// - Important: For group-based lists, prefer `get()` only when fetching the first page for a *single* group
/// in isolation. When the app needs first pages for multiple groups, call
/// ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)`` instead — it returns every group
/// in one request, which is significantly more efficient than calling `get()` per `ChannelList`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit concerned we are adding a lot of docs to one of the most used functions for something that will be disabled for 99% of customers. This will just add too much noise. Maybe we could introduce a separate function called getGrouped() or something like this

Comment on lines +72 to +74
/// - Important: For filter-based lists, loaded channels are reset when the pagination offset is 0 and
/// the cursor is nil. For group-based lists, only ``Pagination/cursor`` is used — the offset is ignored —
/// and the grouped endpoint controls the page contents.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Feels like we should have seperate functions for this special niche feature.

Comment on lines +55 to +68
/// Creates an instance of ``ChannelList`` which represents an array of channels matching to the specified group.
///
/// - Important: The initial state for the group must be fetched with ``ChatClient/queryGroupedChannels(groups:limit:presence:watch:)``
/// which does a batch fetch for all the groups.
public func makeChannelList(with groupKey: String) -> ChannelList {
let channelList = ChannelList(
query: .init(groupKey: groupKey),
dynamicFilter: nil,
client: self
)
// Start tracking immediately, because the first page is meant to be fetches with queryGroupedChannels
syncRepository.startTrackingChannelList(channelList)
return channelList
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just like we have a seperate function for grouped channels, it probably makes sense to have seperate functions to interact with it. Or even return a ChannelGroupedList object.

}

// MARK: -
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove this empty mark?

Revert ChannelList get/loadChannels/loadMoreChannels docs to their
original wording and condense the queryGroupedChannels and ChannelGroup
documentation so the grouped channels API is less prominent.
@laevandus laevandus marked this pull request as draft May 27, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants