Skip to content

Grouped channels endpoint#4071

Closed
martinmitrevski wants to merge 22 commits intov4from
grouped-channels-endpoint
Closed

Grouped channels endpoint#4071
martinmitrevski wants to merge 22 commits intov4from
grouped-channels-endpoint

Conversation

@martinmitrevski
Copy link
Copy Markdown
Contributor

🔗 Issue Links

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

🎯 Goal

  • Add ChatChannelListController.prefill(channels:completion:) for priming controller-local channel data before the first synchronize call while preserving normal pagination, observation, and offline refresh behavior
  • Add ChatClient.groupedQueryChannels(limit:watch:presence:) to fetch grouped channel groups as GroupedChannels, preserving backend group keys and exposing normalized per-group channels and unread counts for integrators
  • Add optional groupedUnreadChannels data to grouped unread websocket events and persist it on CurrentChatUser for integrators

📝 Summary

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

🛠 Implementation

Provide a detailed description of the implementation and explain your decisions if you find them relevant.

🎨 Showcase

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

Before After
img img

🧪 Manual Testing Notes

Explain how this change can be tested manually, if applicable.

☑️ 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

@martinmitrevski martinmitrevski requested a review from a team as a code owner April 20, 2026 11:10
@martinmitrevski martinmitrevski marked this pull request as draft April 20, 2026 11:10
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c4f0d53-501a-4dfe-b9aa-abadcc66a003

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

1 Message
📖 Skipping Danger since the Pull Request is classed as Draft/Work In Progress

Generated by 🚫 Danger

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

SDK Performance

target metric benchmark branch performance status
MessageList Hitches total duration 10 ms 6.68 ms 33.2% 🔼 🟢
Duration 2.6 s 2.55 s 1.92% 🔼 🟢
Hitch time ratio 4 ms per s 2.63 ms per s 34.25% 🔼 🟢
Frame rate 75 fps 78.86 fps 5.15% 🔼 🟢
Number of hitches 1 0.8 20.0% 🔼 🟢


/// A Boolean value that returns whether pagination is finished
public private(set) var hasLoadedAllPreviousChannels: Bool = false
private var loadedChannelsCount = 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.

Thinking about why we need extra state here because it will get out of sync when channel linker inserts or removes channels for this query. Why can't we use channels.count. Prefill saves channels for query so DB observer should reflect that.

Comment on lines +221 to +223
let prefilledChannels = filter.map { runtimeFilter in
channels.filter(runtimeFilter)
} ?? channels
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.

If this is the first snapshot returned by backend, do we need to do this?

Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment on lines +507 to +513
databaseContainer.write { session in
let groupedUnreadChannels = payload.groups.mapValues(\.unreadChannels)
try session.saveCurrentUserGroupedUnreadChannels(groupedUnreadChannels)
}
databaseContainer.write(converting: { session in
try Self.groupedChannels(from: payload, session: session)
}, completion: completion)
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.

Would be better to have a single write there because currently errors from the first one are ignored,

Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment on lines +914 to +948
/// A grouped channels response returned by `ChatClient.groupedQueryChannels`.
public struct GroupedChannels: Equatable {
/// The grouped channel groups returned by the backend, keyed by group name.
public let groups: [String: GroupedChannelsGroup]

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

/// A grouped channels group returned by `ChatClient.groupedQueryChannels`.
public struct GroupedChannelsGroup: Equatable {
/// The channels that belong to this group.
public let channels: [ChatChannel]

/// The total unread channel count in the group.
public let unreadChannels: Int

public init(
channels: [ChatChannel],
unreadChannels: Int
) {
self.channels = channels
let derivedUnreadChannels = channels.reduce(into: 0) { partialResult, channel in
if channel.unreadCount.messages > 0 {
partialResult += 1
}
}

self.unreadChannels = max(unreadChannels, derivedUnreadChannels)
}
}

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.

These should also be moved to Models folder for keeping ChatClient.swift shorter,

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.

we should also change them to final class

Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment thread Sources/StreamChat/ChatClient.swift Outdated
Comment thread CHANGELOG.md Outdated
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

…sent (avoids manually keeping the message count up to date which is part of the count_messages app setting)
// 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.

…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.
@github-actions
Copy link
Copy Markdown

Public Interface

+ public protocol HasGroupedUnreadChannels: Event

+ public struct GroupedChannels: Equatable  
+ 
+   public let groups: [String: GroupedChannelsGroup]

+ public struct GroupedChannelsGroup: Equatable  
+ 
+   public let groupKey: String
+   public let channels: [ChatChannel]
+   public let unreadChannels: Int



- public final class ChannelTruncatedEvent: ChannelSpecificEvent  
+ public final class ChannelTruncatedEvent: ChannelSpecificEvent, HasGroupedUnreadChannels  
+   public let groupedUnreadChannels: GroupedUnreadChannels?

- public final class NotificationChannelDeletedEvent: ChannelSpecificEvent  
+ public final class NotificationChannelDeletedEvent: ChannelSpecificEvent, HasGroupedUnreadChannels  
+   public let groupedUnreadChannels: GroupedUnreadChannels?

- public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount  
+ public final class NotificationMarkReadEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels  
-   public let lastReadMessageId: MessageId?
+   public let groupedUnreadChannels: GroupedUnreadChannels?
-   public let createdAt: Date
+   public let lastReadMessageId: MessageId?
+   public let createdAt: Date

- public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount  
+ public final class NotificationMessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels  
+   public let groupedUnreadChannels: GroupedUnreadChannels?

 public class CurrentChatUser: ChatUser  
-   public let isInvisible: Bool
+   public let groupedUnreadChannels: GroupedUnreadChannels?
-   public let privacySettings: UserPrivacySettings
+   public let isInvisible: Bool
-   public let pushPreference: PushPreference?
+   public let privacySettings: UserPrivacySettings
+   public let pushPreference: PushPreference?

- public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount  
+ public final class MessageNewEvent: ChannelSpecificEvent, HasUnreadCount, HasGroupedUnreadChannels  
+   public let groupedUnreadChannels: GroupedUnreadChannels?

 public class ChatClient  
-   public func upload(_ attachment: StreamAttachment<Payload>,progress: ((Double) -> Void)?,completion: @escaping (Result<UploadedFile, Error>) -> Void)
+   public func queryGroupedChannels(limit: Int? = nil,watch: Bool = false,presence: Bool = false,completion: @escaping @MainActor (Result<GroupedChannels, Error>) -> Void)
-   public func uploadAttachment(localUrl: URL,progress: ((Double) -> Void)?,completion: @escaping (Result<UploadedFile, Error>) -> Void)
+   public func queryGroupedChannels(limit: Int? = nil,watch: Bool = false,presence: Bool = false)async throws -> GroupedChannels
-   public func deleteAttachment(remoteUrl: URL,completion: @escaping (Error?) -> Void)
+   public func upload(_ attachment: StreamAttachment<Payload>,progress: ((Double) -> Void)?,completion: @escaping (Result<UploadedFile, Error>) -> Void)
+   public func uploadAttachment(localUrl: URL,progress: ((Double) -> Void)?,completion: @escaping (Result<UploadedFile, Error>) -> Void)
+   public func deleteAttachment(remoteUrl: URL,completion: @escaping (Error?) -> Void)

- public final class NotificationMarkUnreadEvent: ChannelSpecificEvent  
+ public final class NotificationMarkUnreadEvent: ChannelSpecificEvent, HasGroupedUnreadChannels  
-   public let unreadMessagesCount: Int
+   public let groupedUnreadChannels: GroupedUnreadChannels?
+   public let unreadMessagesCount: Int

 public class ChatChannelListController: DataController, DelegateCallable, DataStoreProvider  
-   public let query: ChannelListQuery
+   public private var query: ChannelListQuery
-   @available(*, deprecated, message: "Please use `markAllRead` available in `CurrentChatUserController`") public func markAllRead(completion: ((Error?) -> Void)? = nil)
+   public func prefill(group: GroupedChannelsGroup,completion: ((Error?) -> Void)? = nil)
+   @available(*, deprecated, message: "Please use `markAllRead` available in `CurrentChatUserController`") public func markAllRead(completion: ((Error?) -> Void)? = nil)

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

SDK Size

title v4 branch diff status
StreamChat 8.6 MB 8.67 MB +77 KB 🟢
StreamChatUI 4.94 MB 4.94 MB 0 KB 🟢

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChat XCSize

Object Diff (bytes)
CDNClient.o +16604
ChannelListPayload.o +14009
ChannelListController.o +8894
SyncOperations.o +8610
ChannelListUpdater.o +7791
Show 31 more objects
Object Diff (bytes)
RequestEncoder.o +6718
ChannelController.o +4952
ChatClient.o +4112
EndpointPath.o +2103
ChatMessage.o +2092
PollVoteDTO.o +2080
ChannelListLinker.o +1704
GroupedChannels.o +1591
CurrentUserDTO.o +1334
CurrentUserController.o -1308
ChannelListQuery.o +1306
SyncRepository.o +928
APIClient.o +818
NotificationEvents.o +652
ChannelEvents.o +536
EventPayload.o +437
CurrentUserPayloads.o -332
ErrorPayload.o +328
AudioAnalysisEngine.o +308
AttachmentDownloader.o +291
PollsPayloads.o -268
UserListController.o -264
MessageEvents.o +252
CurrentUser.o +194
Event.o +162
CurrentUserUpdater.o -160
ThreadDTO.o +136
DraftPayloads.o -95
DatabaseSession.o +92
ChannelDTO.o +76
UserController.o -48

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamChatUI XCSize

Object Diff (bytes)
ChatChannelListRouter.o -44

@sonarqubecloud
Copy link
Copy Markdown

@laevandus
Copy link
Copy Markdown
Contributor

Closing the PR because we are targeting develop for this feature.

@laevandus laevandus closed this May 6, 2026
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.

3 participants