Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

## StreamChat
### βœ… Added
- Add `ChannelListQuery(predefinedFilter:filterValues:sortValues:)` for creating channel list queries with predefined filters [#4113](https://github.com/GetStream/stream-chat-swift/pull/4113)

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

Align Upcoming changelog structure with the required format.

Use ### Added / ### Fixed / ### Changed (without emoji), and include the ## StreamChatCommonUI subsection under # Upcoming as required.

As per coding guidelines, "Follow Keep a Changelog format with ### Added, ### Fixed, ### Changed subsections in CHANGELOG.md" and "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 - 9, Update the Upcoming changelog section to
follow "Keep a Changelog" structure: replace emoji-prefixed subsection headings
like "βœ… Added" with plain "### Added" (and use "### Fixed"/"### Changed" where
applicable), ensure the existing entry mentioning
ChannelListQuery(predefinedFilter:filterValues:sortValues:) is under "##
StreamChat" and move or add empty subsections for "## StreamChatUI" and "##
StreamChatCommonUI" under the same "# Upcoming" parent so the file contains
separate "StreamChat", "StreamChatUI", and "StreamChatCommonUI" subsections with
standardized "### Added/Fixed/Changed" headings.

## StreamChatUI
### 🐞 Fixed
- Fix attachment upload overlay text and action buttons being illegible in dark mode [#4111](https://github.com/GetStream/stream-chat-swift/pull/4111)
Expand Down
38 changes: 38 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ final class DemoChatChannelListVC: ChatChannelListVC {

lazy var premiumTaggedChannelsQuery: ChannelListQuery = .init(filter: .in(.filterTags, values: ["premium"]))

lazy var predefinedMessagingChannelsQuery: ChannelListQuery = .init(
predefinedFilter: "user_per_channel_type_channels",
filterValues: ["channel_type": .string(ChannelType.messaging.rawValue), "user_id": .string(currentUserId)],
sortValues: nil
)

lazy var predefinedLivestreamChannelsQuery: ChannelListQuery = .init(
predefinedFilter: "user_per_channel_type_channels",
filterValues: ["channel_type": .string(ChannelType.livestream.rawValue), "user_id": .string(currentUserId)],
sortValues: nil
)
lazy var livestreamChannelsQuery: ChannelListQuery = .init(filter: .equal(.type, to: .livestream))

var demoRouter: DemoChatChannelListRouter? {
Expand Down Expand Up @@ -270,6 +281,23 @@ final class DemoChatChannelListVC: ChatChannelListVC {
}
)

let predefinedMessagingChannelsAction = UIAlertAction(
title: "Predefined: messaging",
style: .default,
handler: { [weak self] _ in
self?.title = "Predefined: messaging"
self?.setPredefinedMessagingChannelsQuery()
}
)

let predefinedLivestreamChannelsAction = UIAlertAction(
title: "Predefined: livestream",
style: .default,
handler: { [weak self] _ in
self?.title = "Predefined: livestream"
self?.setPredefinedLivestreamChannelsQuery()
}
)
let livestreamChannelsAction = UIAlertAction(
title: "Livestream Channels",
style: .default
Expand All @@ -294,6 +322,8 @@ final class DemoChatChannelListVC: ChatChannelListVC {
equalMembersAction,
channelRoleChannelsAction,
taggedChannelsAction,
predefinedMessagingChannelsAction,
predefinedLivestreamChannelsAction,
livestreamChannelsAction
].sorted(by: { $0.title ?? "" < $1.title ?? "" }),
preferredStyle: .actionSheet,
Expand Down Expand Up @@ -355,6 +385,14 @@ final class DemoChatChannelListVC: ChatChannelListVC {
replaceQuery(premiumTaggedChannelsQuery)
}

func setPredefinedMessagingChannelsQuery() {
replaceQuery(predefinedMessagingChannelsQuery)
}

func setPredefinedLivestreamChannelsQuery() {
replaceQuery(predefinedLivestreamChannelsQuery)
}

func setLivestreamChannelsQuery() {
replaceQuery(livestreamChannelsQuery)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,48 @@ import Foundation
struct ChannelListPayload {
/// A list of channels response (see `ChannelQuery`).
let channels: [ChannelPayload]

/// Server-resolved predefined filter, present only when the query was made with a predefined filter.
let predefinedFilter: PredefinedFilterPayload?

init(channels: [ChannelPayload], predefinedFilter: PredefinedFilterPayload? = nil) {
self.channels = channels
self.predefinedFilter = predefinedFilter
}
}

extension ChannelListPayload: Decodable {
enum CodingKeys: String, CodingKey {
case channels
case predefinedFilter = "predefined_filter"
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let channels = try container
.decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels)
let predefinedFilter = try container
.decodeIfPresent(PredefinedFilterPayload.self, forKey: .predefinedFilter)

self.init(
channels: channels
channels: channels,
predefinedFilter: predefinedFilter
)
}
}

final class PredefinedFilterPayload: Decodable, Sendable {
let name: String
let filter: [String: RawJSON]
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 won't use the typesafe Filter we use for the other cases? (Same for sorting)

Copy link
Copy Markdown
Contributor Author

@laevandus laevandus May 29, 2026

Choose a reason for hiding this comment

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

This gets saved in CoreData first so it is better to keep the form what backend gives us. The typed filter transformation can change the encoded JSON format (e.g. decoding adds implicit $eq).

Other case is that OpenAPI decoding will give me the same form so it is easier to migrate that later on.

let sort: [[String: RawJSON]]

init(name: String, filter: [String: RawJSON], sort: [[String: RawJSON]]) {
self.name = name
self.filter = filter
self.sort = sort
}
}

struct ChannelPayload {
let channel: ChannelDetailPayload

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, @unchecked Sendable {
/// The query specifying and filtering the list of channels.
public let query: ChannelListQuery
public internal(set) var query: ChannelListQuery

/// The `ChatClient` instance this controller belongs to.
public let client: ChatClient
Expand All @@ -51,11 +51,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
}

/// The worker used to fetch the remote data and communicate with servers.
private lazy var worker: ChannelListUpdater = self.environment
.channelQueryUpdaterBuilder(
client.databaseContainer,
client.apiClient
)
private let worker: ChannelListUpdater

/// The worker used to update current user data.
private lazy var currentUserUpdater: CurrentUserUpdater = self.environment
Expand All @@ -82,8 +78,15 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
}

private(set) lazy var channelListObserver: BackgroundListDatabaseObserver<ChatChannel, ChannelDTO> = {
if let updated = worker.loadPredefinedFilter(for: query) {
query = updated
}
Comment on lines +81 to +83
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 supporting app relaunches where API call has not finished and we load the filter from CoreData and pass it to FRC.

return makeChannelListObserver()
}()

private func makeChannelListObserver() -> BackgroundListDatabaseObserver<ChatChannel, ChannelDTO> {
let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: client.config)
let observer = self.environment.createChannelListDatabaseObserver(
let observer = environment.createChannelListDatabaseObserver(
client.databaseContainer,
request,
{ try $0.asModel() },
Expand All @@ -101,7 +104,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
}
}
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 +120,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt

private let filter: (@Sendable (ChatChannel) -> Bool)?
private let environment: Environment
private lazy var channelListLinker: ChannelListLinker = self.environment
.channelListLinkerBuilder(
query, filter, client.config, client.databaseContainer, worker, client.channelWatcherHandler
private var channelListLinker: ChannelListLinker

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

/// Creates a new `ChannelListController`.
///
Expand All @@ -139,21 +150,28 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
self.filter = filter
self.environment = environment
self.deliveryCriteriaValidator = environment.deliveryCriteriaValidatorBuilder()
let worker = environment.channelQueryUpdaterBuilder(client.databaseContainer, client.apiClient)
self.worker = worker
channelListLinker = environment.channelListLinkerBuilder(
query,
filter,
client.config,
client.databaseContainer,
worker,
client.channelWatcherHandler
)
super.init()
}

override public func synchronize(_ completion: (@MainActor (_ error: Error?) -> Void)? = nil) {
startChannelListObserverIfNeeded()
channelListLinker.start(with: client.eventNotificationCenter)
client.syncRepository.startTrackingChannelListController(self)
updateChannelList { [weak self] error in
guard let completion else { return }
self?.callback {
completion(error)
}
updateChannelList { result in
self.callback { completion?(result.error) }
}
}

// MARK: - Actions

/// Loads next channels from backend.
Expand All @@ -179,9 +197,13 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
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
case let .success(updateResult):
self.markChannelsAsDeliveredIfNeeded(channels: updateResult.channels)
self.hasLoadedAllPreviousChannels = updateResult.channels.count < limit
if let updatedQuery = updateResult.updatedQuery {
self.query = updatedQuery
self.updateChannelListObserver()
}
Comment thread
laevandus marked this conversation as resolved.
self.callback { completion?(nil) }
case let .failure(error):
self.callback { completion?(error) }
Expand All @@ -199,28 +221,34 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
// MARK: - Helpers

private func updateChannelList(
_ completion: (@MainActor (_ error: Error?) -> Void)? = nil
_ completion: (@MainActor (Result<ChannelListUpdateResult, Error>) -> Void)? = nil
) {
let limit = query.pagination.pageSize
worker.update(
channelListQuery: query
) { [weak self] result in
switch result {
case let .success(channels):
case let .success(updateResult):
self?.state = .remoteDataFetched
self?.hasLoadedAllPreviousChannels = channels.count < limit
self?.hasLoadedAllPreviousChannels = updateResult.channels.count < limit

// Mark channels as delivered if synchronization was successful
self?.markChannelsAsDeliveredIfNeeded(channels: channels)
self?.markChannelsAsDeliveredIfNeeded(channels: updateResult.channels)

// Predefined filters can update local query representation (query gets backend defined filter and sort which must be set to FRC)
if let updatedQuery = updateResult.updatedQuery {
self?.query = updatedQuery
self?.updateChannelListObserver()
}

self?.callback { completion?(nil) }
self?.callback { completion?(.success(updateResult)) }
case let .failure(error):
self?.state = .remoteDataFetchFailed(ClientError(with: error))
self?.callback { completion?(error) }
self?.callback { completion?(.failure(error)) }
}
}
}

/// Marks channels as delivered if they meet the specified criteria.
/// - Parameter channels: The channels to evaluate for marking as delivered.
private func markChannelsAsDeliveredIfNeeded(channels: [ChatChannel]) {
Expand Down Expand Up @@ -264,6 +292,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
log.error("Failed to perform fetch request with error: \(error). This is an internal error.")
}
}

private func updateChannelListObserver() {
channelListObserver = makeChannelListObserver()
channelListLinker = makeChannelListLinker()
channelListLinker.start(with: client.eventNotificationCenter)
do {
try channelListObserver.startObserving()
} catch {
state = .localDataFetchFailed(ClientError(with: error))
log.error("Failed to update the channel list observer: \(error)")
}
}
}

extension ChatChannelListController {
Expand Down
6 changes: 3 additions & 3 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ extension NSManagedObjectContext {
// the query won't be saved, which will cause any future
// channels to not become linked to this query
if let query = query {
_ = saveQuery(query: query)
_ = saveQuery(query: query, predefinedFilter: payload.predefinedFilter)
}

return payload.channels.compactMapLoggingError { channelPayload in
Expand Down Expand Up @@ -444,7 +444,7 @@ extension NSManagedObjectContext {
}

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

delete(dto)
}
Expand Down Expand Up @@ -477,7 +477,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
Loading