Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 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
708e6b8
Reset base URL and limit page size
laevandus May 18, 2026
3a39732
Add linking for all and handle channel creation
laevandus May 18, 2026
a798045
Remove grouped channels support from ChatChannelListController
laevandus May 19, 2026
c970569
Cleanup channel_custom
laevandus May 19, 2026
89a082c
Remove unused code
laevandus May 19, 2026
51b5bb2
Tidy naming and unread processing
laevandus May 19, 2026
95de344
Major cleanup based on PR review
laevandus May 20, 2026
9e2a4ca
Adjust group unread counts when linking and unlinking channels
laevandus May 20, 2026
714d8ab
Address review comments
laevandus May 20, 2026
d636460
Tidy page size, docs, predicate handling
laevandus May 20, 2026
60971db
Track watch and presence states
laevandus May 21, 2026
1d5043d
Grand renaming
laevandus May 21, 2026
394155c
Handle manual unread count changes when channel moves between groups …
laevandus May 21, 2026
2d4190c
Use channelsPageSize as fetchBatchSize when pageSize is unset
laevandus May 21, 2026
24abf5b
Merge branch 'develop' into grouped-channels-v5
laevandus May 21, 2026
2a59ed5
Add groups parameter to queryGroupedChannels for targeted multi-group…
laevandus May 22, 2026
3c33a56
Query grouped channels requires groups argument
laevandus May 25, 2026
6989801
PR review comments
laevandus May 25, 2026
f94acb3
Set unset page to -1
laevandus May 25, 2026
ca29986
Default watch to true in queryGroupedChannels
laevandus May 26, 2026
a86861e
Tidy grouped channels docs
laevandus May 27, 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 @@ -3,6 +3,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

## StreamChat
### βœ… Added
- Add `ChatClient.queryGroupedChannels(groups: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

Comment on lines +6 to 12
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.

# [5.3.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.3.0)
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
}
}

final class GroupedQueryChannelsRequestGroup: Encodable, Sendable {
let limit: Int?
let next: String?

init(limit: Int?, next: String?) {
self.limit = limit
self.next = next
}
}

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

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

enum CodingKeys: String, CodingKey {
case groups
}

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

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
35 changes: 34 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,39 @@ public class ChatClient: @unchecked Sendable {
}
}

// MARK: - Grouped Channels

/// Fetches the first page of channels for the requested groups in a single request.
///
/// To observe and paginate a group's channels, create a ``ChannelList`` for its
/// ``ChannelGroup/groupKey`` via ``ChatClient/makeChannelList(with:)-(String)`` and read
/// ``ChannelListState/channels``.
///
/// - Parameters:
/// - groups: The group keys to fetch.
/// - limit: The number of channels to return per group. `nil` uses the backend default.
/// - presence: When `true`, includes presence info and streams presence updates over the WebSocket.
/// - watch: When `true`, subscribes to WebSocket events for the returned channels.
///
/// - Returns: The fetched ``ChannelGroup`` values.
/// - Throws: An error while communicating with the Stream API.
@discardableResult public func queryGroupedChannels(
groups: [String],
limit: Int? = nil,
presence: Bool = false,
watch: Bool = true
) async throws -> [ChannelGroup] {
let groupRequests: [String: GroupedQueryChannelsRequestGroup]? = groups.isEmpty ? nil : groups.reduce(into: [:]) { result, key in
result[key] = GroupedQueryChannelsRequestGroup(limit: limit, next: nil)
}
return try await channelListUpdater.queryGroupedChannels(
groups: groupRequests,
limit: groupRequests == nil ? limit : nil,
watch: watch,
presence: presence
)
}

// MARK: - Upload attachments

/// Uploads an attachment to the specified CDN.
Expand Down
19 changes: 13 additions & 6 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,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
}
Comment on lines +284 to +286
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.

dto.memberCount = Int64(clamping: payload.memberCount)

if let messageCount = payload.messageCount {
Expand Down Expand Up @@ -356,7 +358,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 @@ -444,7 +446,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 @@ -477,7 +479,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 All @@ -497,8 +499,13 @@ extension ChannelDTO {
}

request.predicate = NSCompoundPredicate(type: .and, subpredicates: subpredicates)
request.fetchLimit = query.pagination.pageSize
request.fetchBatchSize = query.pagination.pageSize
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
Comment on lines +502 to +508
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 πŸ€”

return request
}

Expand Down
42 changes: 37 additions & 5 deletions Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,38 @@ class ChannelListQueryDTO: NSManagedObject {
/// Serialized `Filter` JSON which can be used in cases the query needs to be repeated, i.e. for newly created channels.
@NSManaged var filterJSONData: Data

/// Next-page cursor returned by the grouped channels endpoint for this group.
/// `nil` means there is no next page (either never paginated or the backend
/// signaled exhaustion). Only meaningful for queries that carry a `groupKey`.
@NSManaged var next: String?

/// The `watch` flag that the original grouped-channels request was issued with.
///
/// Persisted so that subsequent paginated fetches (via `ChannelList`) and the
/// `SyncRepository` refetch after reconnect can reuse the same value the caller
/// passed to `ChatClient.queryGroupedChannels(groups:limit:presence:watch:)` instead of
/// silently downgrading to `false`.
///
/// When `watch` is `false`, ordinary channel and member WebSocket events
/// (`message.new`, `channel.updated`, etc.) still arrive for channels the current
/// user is a member of. What `watch == true` additionally enables is the
/// watcher-scoped event stream β€” most notably typing indicators
/// (`typing.start` / `typing.stop`) β€” so any UI that needs typing indicators
/// on a grouped channel must pass `watch: true` on the initial query.
///
/// Only meaningful for queries that carry a `groupKey`; filter-based queries
/// derive watching from `ChannelListQuery.options`.
@NSManaged var watch: Bool

/// The `presence` flag that the original grouped-channels request was issued with.
///
/// Persisted so that subsequent paginated fetches and sync refetches reuse the
/// same value, keeping presence info in responses and presence updates on the
/// WebSocket consistent across the lifetime of the group subscription.
///
/// Only meaningful for queries that carry a `groupKey`.
@NSManaged var presence: Bool

// MARK: - Relationships

@NSManaged var channels: Set<ChannelDTO>
Expand All @@ -35,21 +67,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

Comment on lines +70 to 85
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.

let jsonData: Data
do {
Expand Down
39 changes: 39 additions & 0 deletions Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Foundation

@objc(CurrentUserDTO)
class CurrentUserDTO: NSManagedObject {
/// JSON-encoded `[groupKey: unreadCount]`.
@NSManaged var unreadGroupedChannelsCounts: Data?
@NSManaged var unreadChannelsCount: Int64
@NSManaged var unreadMessagesCount: Int64
@NSManaged var unreadThreadsCount: Int64
Expand Down Expand Up @@ -144,6 +146,30 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession {
}
}

/// Merges per-group unread channel counts into `CurrentUserDTO.unreadChannelCountsByGroup`.
/// Called from `queryGroupedChannels` responses and from WS events carrying
/// `grouped_unread_channels`; both paths use merge semantics, so keys absent from the input
/// are left untouched and a group that disappears from a server snapshot will keep its
/// locally-cached count until something explicitly clears it.
func mergeCurrentUserUnreadChannelCountsByGroup(_ unreadChannelCountsByGroup: [String: Int]) throws {
invalidateCurrentUserCache()

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

dto.unreadChannelCountsByGroup = (dto.unreadChannelCountsByGroup ?? [:]).merging(unreadChannelCountsByGroup) { _, new in new }
}

func adjustUnreadChannelCount(forGroup groupKey: String, by delta: Int) {
invalidateCurrentUserCache()
guard let dto = currentUser, var counts = dto.unreadChannelCountsByGroup, let existing = counts[groupKey] else {
return
}
counts[groupKey] = max(0, existing + delta)
dto.unreadChannelCountsByGroup = counts
}

func saveCurrentUserDevices(_ devices: [DevicePayload], clearExisting: Bool) throws -> [DeviceDTO] {
invalidateCurrentUserCache()

Expand Down Expand Up @@ -212,6 +238,18 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession {
}
}

extension CurrentUserDTO {
var unreadChannelCountsByGroup: [String: Int]? {
get {
guard let unreadGroupedChannelsCounts else { return nil }
return try? JSONDecoder.default.decode([String: Int].self, from: unreadGroupedChannelsCounts)
}
set {
unreadGroupedChannelsCounts = newValue.flatMap { try? JSONEncoder.default.encode($0) }
}
}
}

extension CurrentUserDTO {
override class func prefetchedRelationshipKeyPaths() -> [String] {
[
Expand Down Expand Up @@ -282,6 +320,7 @@ extension CurrentChatUser {
messages: Int(dto.unreadMessagesCount),
threads: Int(dto.unreadThreadsCount)
),
unreadChannelCountsByGroup: dto.unreadChannelCountsByGroup,
mutedChannels: mutedChannels,
privacySettings: .init(
typingIndicators: .init(enabled: dto.isTypingIndicatorsEnabled),
Expand Down
Loading