Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b0bef38
Initial implementation of grouped channels
martinmitrevski Apr 13, 2026
b03d0ba
Updated grouped endpoint
martinmitrevski Apr 14, 2026
8da29bd
Updated payload
martinmitrevski Apr 15, 2026
1cdf026
Small updates
martinmitrevski Apr 17, 2026
a98243c
Unread count fixes
martinmitrevski Apr 17, 2026
2b1e85f
Small Fixes
martinmitrevski Apr 17, 2026
a348c09
Added events payload
martinmitrevski Apr 17, 2026
61becd4
Save the grouped unread channels when doing the grouped endpoint
martinmitrevski Apr 17, 2026
4fc7e3e
Fix bug with linking
martinmitrevski Apr 19, 2026
28ea1cd
Removed conversion to payload
martinmitrevski Apr 20, 2026
5acceea
Remove unused code
martinmitrevski Apr 20, 2026
72e4a87
Fix tests
martinmitrevski Apr 20, 2026
79e1909
Remove message unread count from grouped response
martinmitrevski Apr 21, 2026
ea0bf5a
Removed unread count from the payload
martinmitrevski Apr 21, 2026
394ae25
Add messageCount filter key
laevandus Apr 22, 2026
88eb99a
Tidy ChatClient by reorganising query grouped channels
laevandus Apr 22, 2026
253989d
User class for request and response types, tidy changelog
laevandus Apr 22, 2026
d99da83
Use local message count for filtering when server provided is not pre…
laevandus Apr 22, 2026
4822ee3
Sync pre-filled channel list controllers with query grouped channels …
laevandus Apr 23, 2026
b035571
Update fetch limit for avoiding to keep track of prefilled channel count
laevandus Apr 23, 2026
51b7829
Route grouped-channel prefill through ChannelListQuery.groupKey and p…
laevandus Apr 23, 2026
f7d1f02
Make inits internal
laevandus Apr 23, 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- Add `ChatChannelListController.prefill(group:completion:)` for priming controller-local channel data [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071)
- Add `ChatClient.queryGroupedChannels(limit:watch:presence:)` to fetch grouped channels with per group unread counts [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071)
- Add optional `groupedUnreadChannels` data to relevant web-socket events and to `CurrentChatUser` [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071)

### 🔄 Changed
- Make grouped channels decoding tolerate missing `unread_count` and `unread_channels` fields in group buckets, matching the current OpenAPI schema [#4071](https://github.com/GetStream/stream-chat-swift/pull/4071)

# [4.99.1](https://github.com/GetStream/stream-chat-swift/releases/tag/4.99.1)
_April 01, 2026_
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,60 @@ extension ChannelListPayload: Decodable {
}
}

final class GroupedQueryChannelsRequestBody: Encodable {
let limit: Int?
let watch: Bool
let presence: Bool

init(limit: Int?, watch: Bool, presence: Bool) {
self.limit = limit
self.watch = watch
self.presence = presence
}
}

final class GroupedQueryChannelsPayload: Decodable {
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 {
let channels: [ChannelPayload]
let unreadChannels: Int

init(channels: [ChannelPayload], unreadChannels: Int) {
self.channels = channels
self.unreadChannels = unreadChannels
}

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

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

struct ChannelPayload {
let channel: ChannelDetailPayload

Expand Down
32 changes: 31 additions & 1 deletion Sources/StreamChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ public class ChatClient {
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 @@ -645,6 +645,36 @@ public class ChatClient {
}
}

// MARK: - Grouped Channels

/// Queries grouped channel groups for the app.
public func queryGroupedChannels(
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.

Reminder that sync repository needs to use this endpoint as well for not spamming query channels endpoint when app reconnects.

limit: Int? = nil,
watch: Bool = false,
presence: Bool = false,
completion: @escaping @MainActor (Result<GroupedChannels, Error>) -> Void
) {
channelListUpdater.queryGroupedChannels(
limit: limit,
watch: watch,
presence: presence,
completion: completion
)
}

/// Queries grouped channel groups for the app.
public func queryGroupedChannels(
limit: Int? = nil,
watch: Bool = false,
presence: Bool = false
) async throws -> GroupedChannels {
try await withCheckedThrowingContinuation { continuation in
queryGroupedChannels(limit: limit, watch: watch, presence: presence) { 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 {
/// 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

Choose a reason for hiding this comment

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

Reminder for this change


/// 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 @@ -35,7 +35,7 @@ extension ChatClient {
/// - Note: For an async-await alternative of the `ChatChannelListController`, please check ``ChannelList`` in the async-await supported [state layer](https://getstream.io/chat/docs/sdk/ios/client/state-layer/state-layer-overview/).
public class ChatChannelListController: DataController, DelegateCallable, DataStoreProvider {
/// The query specifying and filtering the list of channels.
public let query: ChannelListQuery
public private(set) var query: ChannelListQuery

/// The `ChatClient` instance this controller belongs to.
public let client: ChatClient
Expand Down Expand Up @@ -69,6 +69,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt

/// A Boolean value that returns whether pagination is finished
public private(set) var hasLoadedAllPreviousChannels: Bool = false
@Atomic private var shouldSkipInitialRemoteUpdate = false

/// A type-erased delegate.
var multicastDelegate: MulticastDelegate<ChatChannelListControllerDelegate> = .init() {
Expand Down Expand Up @@ -158,6 +159,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
startChannelListObserverIfNeeded()
channelListLinker.start(with: client.eventNotificationCenter)
client.syncRepository.startTrackingChannelListController(self)

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

updateChannelList(completion)
}

Expand Down Expand Up @@ -194,6 +207,50 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
}
}

/// Prefills the controller with an initial channel list snapshot and skips the first remote
/// `queryChannels` request when `synchronize()` is called afterwards.
///
/// The prefetched channels are persisted in the local storage and linked only to this
/// controller query, so pagination, local observation and offline refresh keep working.
public func prefill(
group: GroupedChannelsGroup,
completion: ((Error?) -> Void)? = nil
) {
let prefilledChannels = filter.map { runtimeFilter in
group.channels.filter(runtimeFilter)
} ?? group.channels
let prefilledGroup = GroupedChannelsGroup(
groupKey: group.groupKey,
channels: prefilledChannels,
unreadChannels: group.unreadChannels
)
// This changes filter hash to use static group key
query.groupKey = group.groupKey

worker.prefill(group: prefilledGroup, for: query) { [weak self] result in
guard let self else { return }
switch result {
case let .success(savedChannels):
self.shouldSkipInitialRemoteUpdate = true
// Prefill can come from a differently sized grouped endpoint page, so we can
// only conclude pagination is exhausted when no channels were provided at all.
self.hasLoadedAllPreviousChannels = savedChannels.isEmpty
// When prefilling with a lot of channels, make the `channels` property to reflect it
// This makes channels.count to reflect the currently loaded channels count
if prefilledChannels.count > self.query.pagination.pageSize {
self.channelListObserver.updateFetchLimit(prefilledChannels.count)
}
self.callback {
completion?(nil)
}
case let .failure(error):
self.callback {
completion?(error)
}
}
}
}

@available(*, deprecated, message: "Please use `markAllRead` available in `CurrentChatUserController`")
public func markAllRead(completion: ((Error?) -> Void)? = nil) {
worker.markAllRead { error in
Expand All @@ -206,8 +263,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
// MARK: - Internal

func refreshLoadedChannels(completion: @escaping (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
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ class BackgroundDatabaseObserver<Item, DTO: NSManagedObject> {
}
}

/// Updates the underlying fetch request's `fetchLimit` and re-runs `performFetch`
/// on the FRC's managed object context. Use this to grow (or shrink) the set of
/// items the observer exposes without tearing it down and losing delegate wiring.
func updateFetchLimit(_ newLimit: Int) {
frc.fetchRequest.fetchLimit = newLimit
frc.managedObjectContext.perform { [weak self] in
guard let self else { return }
do {
try self.frc.performFetch()
} catch {
log.error("Failed to re-fetch after updating fetchLimit to \(newLimit): \(error)")
return
}
self.updateItems(nil)
}
}

/// Starts observing the changes in the database.
/// - Throws: An error if the fetch fails.
func startObserving() throws {
Expand Down
10 changes: 6 additions & 4 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ extension NSManagedObjectContext {
dto.deletedAt = payload.deletedAt?.bridgeDate
dto.updatedAt = payload.updatedAt.bridgeDate
dto.defaultSortingAt = (payload.lastMessageAt ?? payload.createdAt).bridgeDate
dto.lastMessageAt = payload.lastMessageAt?.bridgeDate
if let lastMessageAt = payload.lastMessageAt {
dto.lastMessageAt = lastMessageAt.bridgeDate
}
dto.memberCount = Int64(clamping: payload.memberCount)

if let messageCount = payload.messageCount {
Expand Down Expand Up @@ -349,7 +351,7 @@ extension NSManagedObjectContext {
dto.reads.formUnion(reads)

try payload.messages.forEach { _ = try saveMessage(payload: $0, channelDTO: dto, syncOwnReactions: true, cache: cache) }

var pendingMessages = Set<MessageDTO>()
try payload.pendingMessages?.forEach {
let pending = try saveMessage(
Expand Down Expand Up @@ -441,7 +443,7 @@ extension NSManagedObjectContext {
}

func delete(query: ChannelListQuery) {
guard let dto = channelListQuery(filterHash: query.filter.filterHash) else { return }
guard let dto = channelListQuery(query) else { return }

delete(dto)
}
Expand Down Expand Up @@ -474,7 +476,7 @@ extension ChannelDTO {

request.sortDescriptors = sortDescriptors.isEmpty ? [ChannelListSortingKey.defaultSortDescriptor] : sortDescriptors

let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.filter.filterHash)
let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.queryHash)
let notDeleted = NSPredicate(format: "deletedAt == nil")

var subpredicates: [NSPredicate] = [
Expand Down
10 changes: 5 additions & 5 deletions Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,21 @@ class ChannelListQueryDTO: NSManagedObject {
}

extension NSManagedObjectContext {
func channelListQuery(filterHash: String) -> ChannelListQueryDTO? {
ChannelListQueryDTO.load(filterHash: filterHash, context: self)
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

let jsonData: Data
do {
Expand Down
Loading
Loading