Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Channel List .hasUnread filter #3340

Merged
merged 10 commits into from
Jul 30, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Improve performance of `ChatChannel` and `ChatMessage` equality checks [#3335](https://github.com/GetStream/stream-chat-swift/pull/3335)
### ✅ Added
- Expose `MissingConnectionId` + `InvalidURL` + `InvalidJSON` Errors [#3332](https://github.com/GetStream/stream-chat-swift/pull/3332)
- Add `.hasUnread` filter key to `ChannelListQuery` [#3340](https://github.com/GetStream/stream-chat-swift/pull/3340)
### 🐞 Fixed
- Fix a rare issue with incorrect message order when sending multiple messages while offline [#3316](https://github.com/GetStream/stream-chat-swift/issues/3316)
- Fix sorting channel list by unread count [#3340](https://github.com/GetStream/stream-chat-swift/pull/3340)

# [4.60.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.60.0)
_July 18, 2024_
Expand Down
26 changes: 25 additions & 1 deletion DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ final class DemoChatChannelListVC: ChatChannelListVC {
.equal(.hidden, to: true)
]))

lazy var unreadChannelsQuery: ChannelListQuery = .init(filter: .and([
.containMembers(userIds: [currentUserId]),
.hasUnread
]), sort: [.init(key: .unreadCount, isAscending: false)])

lazy var mutedChannelsQuery: ChannelListQuery = .init(filter: .and([
.containMembers(userIds: [currentUserId]),
.equal(.muted, to: true)
Expand Down Expand Up @@ -113,6 +118,15 @@ final class DemoChatChannelListVC: ChatChannelListVC {
}
)

let unreadChannelsAction = UIAlertAction(
title: "Unread Channels",
style: .default,
handler: { [weak self] _ in
self?.title = "Unread Channels"
self?.setUnreadChannelsQuery()
}
)

let coolChannelsAction = UIAlertAction(
title: "Cool Channels",
style: .default,
Expand All @@ -133,7 +147,13 @@ final class DemoChatChannelListVC: ChatChannelListVC {

presentAlert(
title: "Filter Channels",
actions: [defaultChannelsAction, hiddenChannelsAction, mutedChannelsAction, coolChannelsAction],
actions: [
defaultChannelsAction,
unreadChannelsAction,
hiddenChannelsAction,
mutedChannelsAction,
coolChannelsAction
],
preferredStyle: .actionSheet,
sourceView: filterChannelsButton
)
Expand All @@ -143,6 +163,10 @@ final class DemoChatChannelListVC: ChatChannelListVC {
replaceQuery(hiddenChannelsQuery)
}

func setUnreadChannelsQuery() {
replaceQuery(unreadChannelsQuery)
}

func setMutedChannelsQuery() {
replaceQuery(mutedChannelsQuery)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class ChannelDTO: NSManagedObject {
@NSManaged var messages: Set<MessageDTO>
@NSManaged var pinnedMessages: Set<MessageDTO>
@NSManaged var reads: Set<ChannelReadDTO>
@NSManaged var currentUserUnreadMessagesCount: Int32
@NSManaged var watchers: Set<UserDTO>
@NSManaged var memberListQueries: Set<ChannelMemberListQueryDTO>
@NSManaged var previewMessage: MessageDTO?
Expand All @@ -75,6 +76,16 @@ class ChannelDTO: NSManagedObject {
return
}

// Update the unreadMessagesCount for the current user.
// At the moment this computed property is used for `hasUnread` automatic channel list filtering.
if let currentUserId = managedObjectContext?.currentUser?.user.id {
let currentUserUnread = reads.first(where: { $0.user.id == currentUserId })
let newUnreadCount = currentUserUnread?.unreadMessageCount ?? 0
if newUnreadCount != currentUserUnreadMessagesCount {
currentUserUnreadMessagesCount = newUnreadCount
}
}

// Change to the `truncatedAt` value have effect on messages, we need to mark them dirty manually
// to triggers related FRC updates
if changedValues().keys.contains("truncatedAt") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<attribute name="cid" attributeType="String"/>
<attribute name="cooldownDuration" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currentUserUnreadMessagesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="defaultSortingAt" attributeType="Date" usesScalarValueType="NO" spotlightIndexingEnabled="YES"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="extraData" attributeType="Binary"/>
Expand Down
27 changes: 27 additions & 0 deletions Sources/StreamChat/Query/ChannelListQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public extension Filter where Scope: AnyChannelListFilterScope {
static var noTeam: Filter<Scope> {
.equal(.team, to: nil)
}

/// Filter for fetching only the unread channels.
static var hasUnread: Filter<Scope> {
.equal(.hasUnread, to: true)
}
}

extension Filter where Scope: AnyChannelListFilterScope {
Expand Down Expand Up @@ -162,6 +167,28 @@ public extension FilterKey where Scope: AnyChannelListFilterScope {
static var lastUpdatedAt: FilterKey<Scope, Date> { .init(rawValue: "last_updated", keyPathString: #keyPath(ChannelDTO.lastMessageAt)) }
}

/// Internal filter queries for the channel list.
/// These ones are helpers that should be used by an higher-level filter.
internal extension FilterKey where Scope: AnyChannelListFilterScope {
/// Filter for fetching only the unread channels.
/// Supported operators: `equal`, and only `true` is supported.
static var hasUnread: FilterKey<Scope, Bool> {
.init(
rawValue: "has_unread",
keyPathString: nil,
predicateMapper: { op, hasUnread in
let key = #keyPath(ChannelDTO.currentUserUnreadMessagesCount)
switch op {
case .equal:
return NSPredicate(format: hasUnread ? "\(key) > 0" : "\(key) <= 0")
default:
return nil
}
}
)
}
}

/// A query is used for querying specific channels from backend.
/// You can specify filter, sorting, pagination, limit for fetched messages in channel and other options.
public struct ChannelListQuery: Encodable {
Expand Down
2 changes: 1 addition & 1 deletion Sources/StreamChat/Query/Filter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ public struct FilterKey<Scope: FilterScope, Value: FilterValue>: ExpressibleBySt

init(
rawValue value: String,
keyPathString: String,
keyPathString: String?,
valueMapper: TypedValueMapper? = nil,
isCollectionFilter: Bool = false,
predicateMapper: TypedPredicateMapper? = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ public struct ChannelListSortingKey: SortingKey, Equatable {
remoteKey: ChannelCodingKeys.cid.rawValue
)

/// Sort channels by unread state. When using this sorting key, every unread channel weighs the same,
/// so they're sorted by `updatedAt`
/// Sort channels by unread state.
///
/// When using this sorting key, every unread channel weighs the same, so they're sorted by `updatedAt`.
///
/// **Note:** If you want to sort by number of unreads, you should use the `unreadCount` sorting key.
public static let hasUnread = Self(
keyPath: \.hasUnread,
localKey: nil,
Expand All @@ -60,7 +63,7 @@ public struct ChannelListSortingKey: SortingKey, Equatable {
/// Sort channels by their unread count.
public static let unreadCount = Self(
keyPath: \.unreadCount,
localKey: nil,
localKey: #keyPath(ChannelDTO.currentUserUnreadMessagesCount),
remoteKey: "unread_count"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1020,53 +1020,6 @@ final class ChannelListController_Tests: XCTestCase {

// MARK: Predicates

private func assertFilterPredicate(
_ filter: @autoclosure () -> Filter<ChannelListFilterScope>,
channelsInDB: @escaping @autoclosure () -> [ChannelPayload],
expectedResult: @autoclosure () -> [ChannelId],
file: StaticString = #file,
line: UInt = #line
) throws {
/// Ensure that isChannelAutomaticFilteringEnabled is enabled
var config = ChatClientConfig(apiKeyString: .unique)
config.isChannelAutomaticFilteringEnabled = true
client = ChatClient.mock(config: config)

let query = ChannelListQuery(
filter: filter()
)
controller = ChatChannelListController(
query: query,
client: client,
environment: env.environment
)
controllerCallbackQueueID = UUID()
controller.callbackQueue = .testQueue(withId: controllerCallbackQueueID)

// Simulate `synchronize` call
controller.synchronize()
waitForInitialChannelsUpdate()

XCTAssertEqual(controller.channels.map(\.cid), [], file: file, line: line)

// Simulate changes in the DB:
_ = try waitFor {
writeAndWaitForChannelsUpdates({ [query] session in
try channelsInDB().forEach { payload in
try session.saveChannel(payload: payload, query: query, cache: nil)
}
}, completion: $0)
}

// Assert the resulting value is updated
XCTAssertEqual(
controller.channels.map(\.cid.rawValue).sorted(),
expectedResult().map(\.rawValue).sorted(),
file: file,
line: line
)
}

func test_filterPredicate_equal_containsExpectedItems() throws {
let cid = ChannelId.unique

Expand Down Expand Up @@ -1598,6 +1551,53 @@ final class ChannelListController_Tests: XCTestCase {
)
}

func test_filterPredicate_hasUnread_returnsExpectedResults() throws {
let cid1 = ChannelId.unique
let cid2 = ChannelId.unique
let currentUserId = UserId.unique

try assertFilterPredicate(
.hasUnread,
sort: [.init(key: .unreadCount, isAscending: false)],
currentUserId: currentUserId,
channelsInDB: [
.dummy(
channel: .dummy(cid: cid1),
channelReads: [
.init(
user: .dummy(userId: currentUserId),
lastReadAt: .unique,
lastReadMessageId: nil,
unreadMessagesCount: 3
)
]
),
.dummy(channel: .dummy(team: .unique), channelReads: [
.init(
user: .dummy(userId: .unique),
lastReadAt: .unique,
lastReadMessageId: nil,
unreadMessagesCount: 10
)
]),
.dummy(channel: .dummy(team: .unique)),
.dummy(channel: .dummy(team: .unique)),
.dummy(
channel: .dummy(cid: cid2),
channelReads: [
.init(
user: .dummy(userId: currentUserId),
lastReadAt: .unique,
lastReadMessageId: nil,
unreadMessagesCount: 20
)
]
)
],
expectedResult: [cid2, cid1]
)
}

func test_filterPredicate_muted_returnsExpectedResults() throws {
let cid1 = ChannelId.unique
let userId = memberId
Expand Down Expand Up @@ -1671,6 +1671,64 @@ final class ChannelListController_Tests: XCTestCase {

// MARK: - Private Helpers

private func assertFilterPredicate(
_ filter: @autoclosure () -> Filter<ChannelListFilterScope>,
sort: [Sorting<ChannelListSortingKey>] = [],
currentUserId: UserId? = nil,
channelsInDB: @escaping @autoclosure () -> [ChannelPayload],
expectedResult: @autoclosure () -> [ChannelId],
file: StaticString = #file,
line: UInt = #line
) throws {
/// Ensure that isChannelAutomaticFilteringEnabled is enabled
var config = ChatClientConfig(apiKeyString: .unique)
config.isChannelAutomaticFilteringEnabled = true
client = ChatClient.mock(config: config)

if let currentUserId {
try database.writeSynchronously { session in
try session.saveCurrentUser(
payload: .dummy(userId: currentUserId, role: .admin)
)
}
}

let query = ChannelListQuery(
filter: filter(),
sort: sort
)
controller = ChatChannelListController(
query: query,
client: client,
environment: env.environment
)
controllerCallbackQueueID = UUID()
controller.callbackQueue = .testQueue(withId: controllerCallbackQueueID)

// Simulate `synchronize` call
controller.synchronize()
waitForInitialChannelsUpdate()

XCTAssertEqual(controller.channels.map(\.cid), [], file: file, line: line)

// Simulate changes in the DB:
_ = try waitFor {
writeAndWaitForChannelsUpdates({ [query] session in
try channelsInDB().forEach { payload in
try session.saveChannel(payload: payload, query: query, cache: nil)
}
}, completion: $0)
}

// Assert the resulting value is updated
XCTAssertEqual(
controller.channels.map(\.cid.rawValue).sorted(),
expectedResult().map(\.rawValue).sorted(),
file: file,
line: line
)
}

private func makeAddedChannelEvent(with channel: ChatChannel) -> NotificationAddedToChannelEvent {
NotificationAddedToChannelEvent(
channel: channel,
Expand Down
Loading