Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
dcf4bf0
Initial implementation of grouped channels
martinmitrevski Apr 13, 2026
e1c4005
Updated grouped endpoint
martinmitrevski Apr 14, 2026
17eb957
Updated payload
martinmitrevski Apr 15, 2026
6939d94
Small updates
martinmitrevski Apr 17, 2026
652a0fe
Unread count fixes
martinmitrevski Apr 17, 2026
141fc1e
Small Fixes
martinmitrevski Apr 17, 2026
e6e63ce
Added events payload
martinmitrevski Apr 17, 2026
90ee63e
Save the grouped unread channels when doing the grouped endpoint
martinmitrevski Apr 17, 2026
9bcda48
Fix bug with linking
martinmitrevski Apr 19, 2026
55a1a6f
Removed conversion to payload
martinmitrevski Apr 20, 2026
965a983
Remove unused code
martinmitrevski Apr 20, 2026
55ca140
Fix tests
martinmitrevski Apr 20, 2026
9bf95ba
Remove message unread count from grouped response
martinmitrevski Apr 21, 2026
819226e
Removed unread count from the payload
martinmitrevski Apr 21, 2026
f872fb1
Add messageCount filter key
laevandus Apr 22, 2026
94487d1
Tidy ChatClient by reorganising query grouped channels
laevandus Apr 22, 2026
9a193c4
User class for request and response types, tidy changelog
laevandus Apr 22, 2026
8e4f47b
Use local message count for filtering when server provided is not pre…
laevandus Apr 22, 2026
9bf6d49
Sync pre-filled channel list controllers with query grouped channels …
laevandus Apr 23, 2026
bb55a75
Update fetch limit for avoiding to keep track of prefilled channel count
laevandus Apr 23, 2026
82a808b
Route grouped-channel prefill through ChannelListQuery.groupKey and p…
laevandus Apr 23, 2026
0c4e36a
Make inits internal
laevandus Apr 23, 2026
86e6109
Migration fixes
laevandus Apr 28, 2026
74a49bb
Point default BaseURL at Dublin edge for grouped channels testing
laevandus Apr 28, 2026
c796751
Update CHANGELOG to reference PR #4076
laevandus Apr 28, 2026
9053022
Add prefill support for ChannelList
laevandus May 4, 2026
ff593a1
Tidy prefill handling
laevandus May 4, 2026
1b188ad
Merge branch 'develop' into grouped-channels-v5
laevandus May 4, 2026
c6a08ff
Recreate observer instead of changing FRC
laevandus May 4, 2026
b80d551
Support pagination parameters in query grouped channels
laevandus May 11, 2026
a3c6248
Paginate using grouped channels endpoint and add handling for WS even…
laevandus May 14, 2026
1fc76e7
Tidy code
laevandus May 14, 2026
5c5a28e
Merge branch 'develop' into grouped-channels-v5
laevandus May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ _May 13, 2026_
_May 08, 2026_

## 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.channelListController(groupKey:dynamicFilter:)` for observing a single grouped channels group [#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 optional `groupedUnreadChannels` data to relevant web-socket events and `CurrentChatUser` [#4076](https://github.com/GetStream/stream-chat-swift/pull/4076)
### 🐞 Fixed
- Fix image cache misses caused by non-deterministic caching keys in `StreamCDNRequester` [#4075](https://github.com/GetStream/stream-chat-swift/pull/4075)
- Fix `ChatChannel.latestMessages` being wiped on mid-page pagination [#4077](https://github.com/GetStream/stream-chat-swift/pull/4077)
Expand Down
12 changes: 12 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ extension Endpoint {
)
}

static func groupedChannels(
request: GroupedQueryChannelsRequestBody
) -> Endpoint<GroupedQueryChannelsPayload> {
.init(
path: .groupedChannels,
method: .post,
queryItems: nil,
requiresConnectionId: request.watch || request.presence,
body: request
)
}

static func createChannel(query: ChannelQuery) -> Endpoint<ChannelPayload> {
createOrUpdateChannel(path: .createChannel(query.apiPath), query: query)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extension EndpointPath {
switch self {
case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction, .draftMessage:
return true
case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel,
case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .groupedChannels, .updateChannel,
.deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread,
.markAllChannelsRead, .markChannelsDelivered, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadChannelAttachment, .message,
.replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage,
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum EndpointPath: Codable {
case markThreadUnread(cid: ChannelId)

case channels
case groupedChannels
case createChannel(String)
case updateChannel(String)
case deleteChannel(String)
Expand Down Expand Up @@ -116,6 +117,7 @@ enum EndpointPath: Codable {
case .liveLocations: return "users/live_locations"

case .channels: return "channels"
case .groupedChannels: return "channels/grouped"
case let .createChannel(queryString): return "channels/\(queryString)/query"
case let .updateChannel(queryString): return "channels/\(queryString)/query"
case let .deleteChannel(payloadPath): return "channels/\(payloadPath)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,86 @@ extension ChannelListPayload: Decodable {
}
}

final class GroupedQueryChannelsRequestBody: Encodable, Sendable {
let limit: Int?
let groups: [String: GroupedQueryChannelsRequestGroup]?
let watch: Bool
let presence: Bool

init(
limit: Int?,
groups: [String: GroupedQueryChannelsRequestGroup]?,
watch: Bool,
presence: Bool
) {
self.limit = limit
self.groups = groups
self.watch = watch
self.presence = presence
}
}

struct GroupedQueryChannelsRequestGroup: Encodable, Sendable {
let limit: Int?
let next: String?
let prev: String?
}

final class GroupedQueryChannelsPayload: Decodable, Sendable {
let groups: [String: GroupedQueryChannelsGroupPayload]
let duration: String

init(groups: [String: GroupedQueryChannelsGroupPayload], duration: String) {
self.groups = groups
self.duration = duration
}

enum CodingKeys: String, CodingKey {
case groups
case duration
}

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
groups = try container.decode([String: GroupedQueryChannelsGroupPayload].self, forKey: .groups)
duration = try container.decode(String.self, forKey: .duration)
}
}

final class GroupedQueryChannelsGroupPayload: Decodable, Sendable {
let channels: [ChannelPayload]
let unreadChannels: Int
let next: String?
let prev: String?

init(
channels: [ChannelPayload],
unreadChannels: Int,
next: String? = nil,
prev: String? = nil
) {
self.channels = channels
self.unreadChannels = unreadChannels
self.next = next
self.prev = prev
}

enum CodingKeys: String, CodingKey {
case channels
case unreadChannels = "unread_channels"
case next
case prev
}

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
channels = try container.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels)
unreadChannels = try container.decodeIfPresent(Int.self, forKey: .unreadChannels) ?? 0
next = try container.decodeIfPresent(String.self, forKey: .next)
prev = try container.decodeIfPresent(String.self, forKey: .prev)
}
}

struct ChannelPayload {
let channel: ChannelDetailPayload

Expand Down
33 changes: 32 additions & 1 deletion Sources/StreamChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ public class ChatClient: @unchecked Sendable {
eventNotificationCenter.subscribe(handler: handler)
}

// MARK: -
// MARK: - App Settings

/// Fetches the app settings and updates the ``ChatClient/appSettings``.
/// - Parameter completion: The completion block once the app settings has finished fetching.
Expand Down Expand Up @@ -649,6 +649,37 @@ public class ChatClient: @unchecked Sendable {
}
}

// MARK: - Grouped Channels

/// Queries grouped channel groups for the app.
public func queryGroupedChannels(
limit: Int? = nil,
presence: Bool = false,
watch: Bool = false,
completion: @escaping @Sendable (Result<GroupedChannels, Error>) -> Void
) {
channelListUpdater.queryGroupedChannels(
groupPagination: nil,
limit: limit,
watch: watch,
presence: presence,
completion: completion
)
}

/// Queries grouped channel groups for the app.
@discardableResult public func queryGroupedChannels(
limit: Int? = nil,
presence: Bool = false,
watch: Bool = false
) async throws -> GroupedChannels {
try await withCheckedThrowingContinuation { continuation in
queryGroupedChannels(limit: limit, presence: presence, watch: watch) { result in
continuation.resume(with: result)
}
}
}

// MARK: - Upload attachments

/// Uploads an attachment to the specified CDN.
Expand Down
2 changes: 1 addition & 1 deletion Sources/StreamChat/Config/BaseURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
/// A struct representing base URL for `ChatClient`.
public struct BaseURL: CustomStringConvertible, Sendable {
/// The default base URL for StreamChat service.
public static let `default` = BaseURL(urlString: "https://chat.stream-io-api.com/")!
public static let `default` = BaseURL(urlString: "https://chat-edge-dublin-ce2.stream-io-api.com/")!
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.

Note for not committing


/// The base url for StreamChat data center located in the US East Cost.
public static let usEast = BaseURL(urlString: "https://chat-proxy-us-east.stream-io-api.com/")!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@
) -> ChatChannelListController {
.init(query: query, client: self, filter: filter)
}

/// Creates a new `ChannelListController` for a single grouped channels group identified by `groupKey`.
///
/// - Parameters:
/// - groupKey: The group key returned by `queryGroupedChannels` (e.g. `"all"`, `"new"`,
/// `"current"`) used to identify the underlying `ChannelListQueryDTO`.
/// - dynamicFilter: A predicate consulted by ``ChannelListLinker`` when channel-related
/// web-socket events arrive (`message.new`, `notification.added_to_channel`,
/// `notification.message_new`, `channel.updated`, `channel.visible`). Returning `true`
/// keeps/links the channel in this list's `ChannelListQueryDTO`; returning `false`
/// unlinks it. Required so events for channels in other groups don't leak into this
/// list when automatic filtering is enabled.
/// - Returns: A new instance of `ChatChannelListController`.
public func channelListController(
groupKey: String,
dynamicFilter: @escaping @Sendable (ChatChannel) -> Bool
) -> ChatChannelListController {
.init(query: .init(groupKey: groupKey), client: self, filter: dynamicFilter)
}
}

/// `ChatChannelListController` is a controller class which allows observing a list of chat channels based on the provided query.
Expand Down Expand Up @@ -81,15 +100,26 @@
}
}

private(set) lazy var channelListObserver: BackgroundListDatabaseObserver<ChatChannel, ChannelDTO> = {
let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: client.config)
let observer = self.environment.createChannelListDatabaseObserver(
private(set) lazy var channelListObserver: BackgroundListDatabaseObserver<ChatChannel, ChannelDTO> = makeChannelListObserver(
query: query,
minimumFetchLimit: 0
)

private func makeChannelListObserver(
query: ChannelListQuery,
minimumFetchLimit: Int
) -> BackgroundListDatabaseObserver<ChatChannel, ChannelDTO> {
let request = ChannelDTO.channelListFetchRequest(query: query, chatClientConfig: client.config)
let fetchLimit = max(query.pagination.pageSize, minimumFetchLimit)
request.fetchLimit = fetchLimit
request.fetchBatchSize = fetchLimit

let observer = environment.createChannelListDatabaseObserver(
client.databaseContainer,
request,
{ try $0.asModel() },
query.runtimeSortingValues
)

observer.onDidChange = { [weak self] changes in
self?.delegateCallback { [weak self] in
guard let self = self else {
Expand All @@ -101,7 +131,7 @@
}
}
return observer
}()
}

var _basePublishers: Any?
/// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose
Expand All @@ -117,10 +147,13 @@

private let filter: (@Sendable (ChatChannel) -> Bool)?
private let environment: Environment
private lazy var channelListLinker: ChannelListLinker = self.environment
.channelListLinkerBuilder(
private lazy var channelListLinker: ChannelListLinker = makeChannelListLinker(query: query)

private func makeChannelListLinker(query: ChannelListQuery) -> ChannelListLinker {
environment.channelListLinkerBuilder(
query, filter, client.config, client.databaseContainer, worker, client.channelWatcherHandler
)
}

/// Creates a new `ChannelListController`.
///
Expand All @@ -146,12 +179,16 @@
startChannelListObserverIfNeeded()
channelListLinker.start(with: client.eventNotificationCenter)
client.syncRepository.startTrackingChannelListController(self)
updateChannelList { [weak self] error in
guard let completion else { return }
self?.callback {
completion(error)
}

if query.groupKey != nil {
state = .remoteDataFetched
hasLoadedAllPreviousChannels = channels.isEmpty
markChannelsAsDeliveredIfNeeded(channels: Array(channels))
callback { completion?(nil) }
return
}

updateChannelList(completion)
}

// MARK: - Actions
Expand All @@ -174,26 +211,60 @@
return
}

let limit = limit ?? query.pagination.pageSize
var updatedQuery = query
updatedQuery.pagination = Pagination(pageSize: limit, offset: channels.count)
worker.update(channelListQuery: updatedQuery) { result in
switch result {
case let .success(channels):
self.markChannelsAsDeliveredIfNeeded(channels: channels)
self.hasLoadedAllPreviousChannels = channels.count < limit
self.callback { completion?(nil) }
case let .failure(error):
self.callback { completion?(error) }
if let groupKey = query.groupKey {
worker.paginationCursor(for: groupKey) { result in
switch result {
case .failure(let error):
self.callback { completion?(error) }
case .success(let cursor):
guard let cursor else {
self.hasLoadedAllPreviousChannels = true
self.callback { completion?(nil) }
return
}
self.worker.queryGroupedChannels(
groupPagination: .init(groupKey: groupKey, next: cursor, prev: nil),
limit: limit,
watch: true,
presence: true
) { queryResult in
switch queryResult {
case let .success(grouped):
if let group = grouped.groups[groupKey] {
self.hasLoadedAllPreviousChannels = group.next == nil
self.markChannelsAsDeliveredIfNeeded(channels: group.channels)
}
self.callback { completion?(nil) }

Check warning on line 237 in Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 2 closure expressions.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ4mvK5W-Svkp3hBEIdX&open=AZ4mvK5W-Svkp3hBEIdX&pullRequest=4076
case let .failure(error):
self.callback { completion?(error) }

Check warning on line 239 in Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 2 closure expressions.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ4mvK5W-Svkp3hBEIdY&open=AZ4mvK5W-Svkp3hBEIdY&pullRequest=4076
}
}
}
}
} else {
let limit = limit ?? query.pagination.pageSize
var updatedQuery = query
updatedQuery.pagination = Pagination(
pageSize: limit,
offset: channels.count
)
worker.update(channelListQuery: updatedQuery) { result in
switch result {
case let .success(channels):
self.markChannelsAsDeliveredIfNeeded(channels: channels)
self.hasLoadedAllPreviousChannels = channels.count < limit
self.callback { completion?(nil) }
case let .failure(error):
self.callback { completion?(error) }
}
}
}
}

// MARK: - Internal

func refreshLoadedChannels(completion: @escaping @Sendable (Result<Set<ChannelId>, Error>) -> Void) {
let channelCount = channelListObserver.items.count
worker.refreshLoadedChannels(for: query, channelCount: channelCount, completion: completion)
worker.refreshLoadedChannels(for: query, channelCount: channels.count, completion: completion)
}

// MARK: - Helpers
Expand Down
Loading
Loading