Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
1 change: 1 addition & 0 deletions .github/workflows/smoke-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
- run: bundle exec fastlane rubocop
- run: bundle exec fastlane run_swift_format strict:true
- run: bundle exec fastlane validate_public_interface
- run: bundle exec fastlane validate_generated_code
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.

πŸ”₯


build-old-xcode:
name: Build SDKs (Old Xcode)
Expand Down
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
excluded:
- Scripts
- Sources/StreamChat/Generated
- Sources/StreamChatCommonUI/Generated
- Sources/StreamChatUI/StreamSwiftyGif
- Sources/StreamChatUI/StreamNuke
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### πŸ”„ Changed
## 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 +19
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.

# [5.4.0](https://github.com/GetStream/stream-chat-swift/releases/tag/5.4.0)
_May 28, 2026_
Expand Down
43 changes: 43 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ 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 predefinedArchivedHiddenChannelsQuery: ChannelListQuery = .init(
predefinedFilter: "user_per_channel_type_archived_hidden",
filterValues: [
"channel_type": .string(ChannelType.messaging.rawValue),
"hidden": .bool(false),
"user_id": .string(currentUserId),
"archived": .bool(false)
],
sortValues: nil
)
lazy var livestreamChannelsQuery: ChannelListQuery = .init(filter: .equal(.type, to: .livestream))

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

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

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

func setPredefinedMessagingChannelsQuery() {
replaceQuery(predefinedMessagingChannelsQuery)
}

func setPredefinedArchivedHiddenChannelsQuery() {
replaceQuery(predefinedArchivedHiddenChannelsQuery)
}

func setLivestreamChannelsQuery() {
replaceQuery(livestreamChannelsQuery)
}
Expand Down
1 change: 1 addition & 0 deletions Githubfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export INTERFACE_ANALYZER_VERSION='1.0.7'
export SWIFT_LINT_VERSION='0.59.1'
export SWIFT_FORMAT_VERSION='0.58.2'
export SWIFT_GEN_VERSION='6.5.1'
export SOURCERY_VERSION='2.3.0'
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ MAKEFLAGS += --silent
bootstrap:
./Scripts/bootstrap.sh

generate:
sourcery --config Sources/StreamChat/.sourcery.yml

update_dependencies:
echo "πŸ‘‰ Updating Nuke"
make update_nuke version=10.3.3
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ let package = Package(
dependencies: [
.product(name: "StreamCore", package: "stream-core-swift")
],
exclude: ["Info.plist"],
exclude: ["Info.plist", "Generated/PredefinedFilter.stencil"],
resources: [.copy("Database/StreamChatModel.xcdatamodeld")]
),
.target(
Expand Down
13 changes: 13 additions & 0 deletions Scripts/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ if [ "${SKIP_SWIFT_BOOTSTRAP:-}" != true ]; then
sudo sudo rm -f "$BIN_PATH"
sudo sudo ln -s "$INSTALL_DIR/bin/swiftgen" "$BIN_PATH"
swiftgen --version

puts "Install Sourcery v${SOURCERY_VERSION}"
DOWNLOAD_URL="https://github.com/krzysztofzablocki/Sourcery/releases/download/${SOURCERY_VERSION}/sourcery-${SOURCERY_VERSION}.zip"
DOWNLOAD_PATH="/tmp/sourcery-${SOURCERY_VERSION}.zip"
INSTALL_DIR="/usr/local/lib/sourcery"
BIN_PATH="/usr/local/bin/sourcery"
wget "$DOWNLOAD_URL" -O "$DOWNLOAD_PATH"
sudo rm -rf "$INSTALL_DIR"
sudo mkdir -p "$INSTALL_DIR"
sudo unzip -o "$DOWNLOAD_PATH" -d "$INSTALL_DIR"
sudo rm -f "$BIN_PATH"
sudo ln -s "$INSTALL_DIR/bin/sourcery" "$BIN_PATH"
sourcery --version
fi

if [[ ${INSTALL_SONAR-default} == true ]]; then
Expand Down
7 changes: 7 additions & 0 deletions Sources/StreamChat/.sourcery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
sources:
- ./Query/ChannelListQuery.swift
- ./Query/Sorting/ChannelListSortingKey.swift
templates:
- ./Generated/PredefinedFilter.stencil
output:
./Generated/PredefinedFilter+Generated.swift
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]
Comment thread
martinmitrevski marked this conversation as resolved.
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 Down Expand Up @@ -82,8 +82,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 +85 to +87
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.

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.

I had 2 options here:
a) load it async, but then FRC needs to be recreated/reloaded anyway (2 times, one without correct local filter, second with loaded filter) (this is because only CoreData knows the fetches filter JSON)
b) block, load filter, create FRC once

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 +108,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 Down Expand Up @@ -146,11 +153,8 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
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 { [weak self] result in
self?.callback { completion?(result.error) }
}
}

Expand Down Expand Up @@ -179,9 +183,9 @@ 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
self.callback { completion?(nil) }
case let .failure(error):
self.callback { completion?(error) }
Expand All @@ -199,24 +203,30 @@ 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)) }
}
}
}
Expand Down Expand Up @@ -264,6 +274,16 @@ 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()
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