From 03a0661426345e1a1fa9b9f62b6816a404f2d7af Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 28 Nov 2025 11:07:33 +0200 Subject: [PATCH 1/3] Mark messages as unread using timestamp --- .../DemoChatChannelListRouter.swift | 18 ++ .../Endpoints/ChannelEndpoints.swift | 7 +- .../Payloads/MarkUnreadPayload.swift | 34 ++++ .../ChannelController/ChannelController.swift | 42 ++++- .../Database/DTOs/ChannelReadDTO.swift | 22 ++- .../StreamChat/Database/DTOs/MessageDTO.swift | 17 ++ .../StreamChat/Database/DatabaseSession.swift | 4 +- .../Repositories/ChannelRepository.swift | 10 +- .../Repositories/MessageRepository.swift | 30 +++- Sources/StreamChat/StateLayer/Chat.swift | 12 +- .../ChannelReadUpdaterMiddleware.swift | 2 +- .../StreamChat/Workers/ChannelUpdater.swift | 6 +- .../StreamChat/Workers/ReadStateHandler.swift | 10 +- .../ChatChannel/ChatChannelVC.swift | 4 + StreamChat.xcodeproj/project.pbxproj | 6 + .../Database/DatabaseSession_Mock.swift | 4 +- .../Repositories/ChannelRepository_Mock.swift | 6 +- .../Workers/ChannelUpdater_Mock.swift | 8 +- .../Endpoints/ChannelEndpoints_Tests.swift | 34 +++- .../ChannelController_Tests.swift | 14 +- .../Database/DTOs/ChannelReadDTO_Tests.swift | 163 +++++++++++++++++- .../ChannelRepository_Tests.swift | 8 +- .../MessageRepository_Tests.swift | 32 +++- .../Workers/ChannelUpdater_Tests.swift | 8 +- 24 files changed, 430 insertions(+), 71 deletions(-) create mode 100644 Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index 45f17681724..9f9648e7cb5 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -463,6 +463,24 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { } } }), + .init(title: "Mark channel unread with timestamp", isEnabled: true, handler: { [unowned self] _ in + self.rootViewController.presentAlert(title: "Mark messages as unread with timestamp", message: "Marks messages as unread from the last number of days", textFieldPlaceholder: "Days") { offsetInDaysString in + let calendar = Calendar.current + guard let offsetInDays = Int(offsetInDaysString ?? ""), + let date = calendar.date(byAdding: .day, value: -abs(offsetInDays), to: calendar.startOfDay(for: Date())) else { + self.rootViewController.presentAlert(title: "Timestamp offset is not valid") + return + } + channelController.markUnread(from: date) { result in + switch result { + case .failure(let error): + self.rootViewController.presentAlert(title: "Couldn't mark messages as unread \(cid)", message: "\(error)") + case .success: + break + } + } + } + }), .init(title: "Cool channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in channelController.partialChannelUpdate(extraData: ["is_cool": true]) { error in if let error = error { diff --git a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift index c23256605db..f3989019a02 100644 --- a/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift @@ -269,16 +269,13 @@ extension Endpoint { ) } - static func markUnread(cid: ChannelId, messageId: MessageId, userId: UserId) -> Endpoint { + static func markUnread(cid: ChannelId, payload: MarkUnreadPayload) -> Endpoint { .init( path: .markChannelUnread(cid.apiPath), method: .post, queryItems: nil, requiresConnectionId: false, - body: [ - "message_id": messageId, - "user_id": userId - ] + body: payload ) } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift new file mode 100644 index 00000000000..df1b5fc5a36 --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MarkUnreadPayload.swift @@ -0,0 +1,34 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +enum MarkUnreadCriteria: Sendable, Equatable { + /// The ID of the message from where the channel is marked unread + case messageId(String) + /// The timestamp of the message from where the channel is marked unread + case messageTimestamp(Date) +} + +struct MarkUnreadPayload: Encodable, Sendable { + let criteria: MarkUnreadCriteria + let userId: String + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(userId, forKey: .userId) + switch criteria { + case .messageId(let messageId): + try container.encode(messageId, forKey: .messageId) + case .messageTimestamp(let messageTimestamp): + try container.encode(messageTimestamp, forKey: .messageTimestamp) + } + } + + private enum CodingKeys: String, CodingKey { + case messageId = "message_id" + case messageTimestamp = "message_timestamp" + case userId = "user_id" + } +} diff --git a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift index b6981143078..5f54fc3625b 100644 --- a/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift +++ b/Sources/StreamChat/Controllers/ChannelController/ChannelController.swift @@ -1327,7 +1327,47 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP } readStateHandler.markUnread( - from: messageId, + from: .messageId(messageId), + in: channel + ) { [weak self] result in + self?.callback { + completion?(result) + } + } + } + + /// Marks all messages of the channel as unread that were created after the specified timestamp. + /// + /// This method finds the first message with a creation timestamp greater than to the provided timestamp, + /// and marks all messages from that point forward as unread. If no message is found after the timestamp, + /// the operation completes without error but no messages are marked as unread. + /// + /// - Parameters: + /// - timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread. + /// - completion: The completion handler to be called after marking messages as unread. Called with a `Result` containing the updated `ChatChannel` on success, or an `Error` on failure. + public func markUnread(from timestamp: Date, completion: ((Result) -> Void)? = nil) { + /// Perform action only if channel is already created on backend side and have a valid `cid`. + guard let channel = channel else { + let error = ClientError.ChannelNotCreatedYet() + log.error(error.localizedDescription) + callback { + completion?(.failure(error)) + } + return + } + + /// Read events are not enabled for this channel + guard channel.canReceiveReadEvents == true else { + let error = ClientError.ChannelFeatureDisabled("Channel feature: read events is disabled for this channel.") + log.error(error.localizedDescription) + callback { + completion?(.failure(error)) + } + return + } + + readStateHandler.markUnread( + from: .messageTimestamp(timestamp), in: channel ) { [weak self] result in self?.callback { diff --git a/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift index 7725444c288..b4720dfbffb 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift @@ -138,15 +138,29 @@ extension NSManagedObjectContext { func markChannelAsUnread( for cid: ChannelId, userId: UserId, - from messageId: MessageId, + from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, lastReadAt: Date?, unreadMessagesCount: Int? ) { - guard let read = loadChannelRead(cid: cid, userId: userId), - let message = self.message(id: messageId) else { - return + guard let read = loadChannelRead(cid: cid, userId: userId) else { return } + + let findMessageDTO: () -> MessageDTO? = { + switch unreadCriteria { + case .messageId(let messageId): + return self.message(id: messageId) + case .messageTimestamp(let messageTimestamp): + let clientConfig = self.chatClientConfig + return try? MessageDTO.loadMessage( + beforeOrEqual: messageTimestamp, + cid: cid.rawValue, + deletedMessagesVisibility: clientConfig?.deletedMessagesVisibility ?? .alwaysVisible, + shouldShowShadowedMessages: clientConfig?.shouldShowShadowedMessages ?? false, + context: self + ) + } } + guard let message = findMessageDTO() else { return } let lastReadAt = lastReadAt ?? message.createdAt.bridgeDate read.lastReadAt = lastReadAt.bridgeDate diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 4f937582cef..5a6cac9037b 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -607,6 +607,23 @@ class MessageDTO: NSManagedObject { return try context.fetch(request).first } + static func loadMessage( + beforeOrEqual timestamp: Date, + cid: String, + deletedMessagesVisibility: ChatClientConfig.DeletedMessageVisibility, + shouldShowShadowedMessages: Bool, + context: NSManagedObjectContext + ) throws -> MessageDTO? { + let request = NSFetchRequest(entityName: entityName) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + .init(format: "createdAt <= %@", timestamp.bridgeDate), + channelMessagesPredicate(for: cid, deletedMessagesVisibility: deletedMessagesVisibility, shouldShowShadowedMessages: shouldShowShadowedMessages) + ]) + request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)] + request.fetchLimit = 1 + return try context.fetch(request).first + } + static func loadMessages( from fromIncludingDate: Date, to toIncludingDate: Date, diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 5041834d920..592f031afa5 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -396,12 +396,12 @@ protocol ChannelReadDatabaseSession { /// Sets the channel `cid` as read for `userId` func markChannelAsRead(cid: ChannelId, userId: UserId, at: Date) - /// Sets the channel `cid` as unread for `userId` starting from the `messageId` + /// Sets the channel `cid` as unread for `userId` starting from the message id or timestamp. /// Uses `lastReadAt` and `unreadMessagesCount` if passed, otherwise it calculates it. func markChannelAsUnread( for cid: ChannelId, userId: UserId, - from messageId: MessageId, + from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, lastReadAt: Date?, unreadMessagesCount: Int? diff --git a/Sources/StreamChat/Repositories/ChannelRepository.swift b/Sources/StreamChat/Repositories/ChannelRepository.swift index b46561c8ab4..00794ecd3c7 100644 --- a/Sources/StreamChat/Repositories/ChannelRepository.swift +++ b/Sources/StreamChat/Repositories/ChannelRepository.swift @@ -58,23 +58,23 @@ class ChannelRepository { } } - /// Marks a subset of the messages of the channel as unread. All the following messages, including the one that is + /// Marks a subset of the messages of the channel as unread. All the following messages including the one that is /// passed as parameter, will be marked as not read. /// - Parameters: /// - cid: The id of the channel to be marked as unread /// - userId: The id of the current user - /// - messageId: The id of the first message that will be marked as unread. + /// - unreadCriteria: The id or timestamp of the first message that will be marked as unread. /// - lastReadMessageId: The id of the last message that was read. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. func markUnread( for cid: ChannelId, userId: UserId, - from messageId: MessageId, + from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil ) { apiClient.request( - endpoint: .markUnread(cid: cid, messageId: messageId, userId: userId) + endpoint: .markUnread(cid: cid, payload: .init(criteria: unreadCriteria, userId: userId)) ) { [weak self] result in if let error = result.error { completion?(.failure(error)) @@ -86,7 +86,7 @@ class ChannelRepository { session.markChannelAsUnread( for: cid, userId: userId, - from: messageId, + from: unreadCriteria, lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index e6e797c8c27..78bb4a35b67 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -339,7 +339,7 @@ class MessageRepository { /// Fetches a message id before the specified message when sorting by the creation date in the local database. func getMessage( - before messageId: MessageId, + before unreadCriteria: MarkUnreadCriteria, in cid: ChannelId, completion: @escaping (Result) -> Void ) { @@ -349,14 +349,26 @@ class MessageRepository { let deletedMessagesVisibility = clientConfig?.deletedMessagesVisibility ?? .alwaysVisible let shouldShowShadowedMessages = clientConfig?.shouldShowShadowedMessages ?? false do { - let resultId = try MessageDTO.loadMessage( - before: messageId, - cid: cid.rawValue, - deletedMessagesVisibility: deletedMessagesVisibility, - shouldShowShadowedMessages: shouldShowShadowedMessages, - context: context - )?.id - completion(.success(resultId)) + switch unreadCriteria { + case .messageId(let messageId): + let resultId = try MessageDTO.loadMessage( + before: messageId, + cid: cid.rawValue, + deletedMessagesVisibility: deletedMessagesVisibility, + shouldShowShadowedMessages: shouldShowShadowedMessages, + context: context + )?.id + completion(.success(resultId)) + case .messageTimestamp(let messageTimestamp): + let resultId = try MessageDTO.loadMessage( + beforeOrEqual: messageTimestamp, + cid: cid.rawValue, + deletedMessagesVisibility: deletedMessagesVisibility, + shouldShowShadowedMessages: shouldShowShadowedMessages, + context: context + )?.id + completion(.success(resultId)) + } } catch { completion(.failure(error)) } diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 727966a5ab9..7427c6bc26e 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -936,7 +936,17 @@ public class Chat { /// - Throws: An error while communicating with the Stream API. public func markUnread(from messageId: MessageId) async throws { guard let channel = await state.channel else { throw ClientError.ChannelNotCreatedYet() } - try await readStateHandler.markUnread(from: messageId, in: channel) + try await readStateHandler.markUnread(from: .messageId(messageId), in: channel) + } + + /// Marks all the messages after the specified timestamp as unread. + /// + /// - Parameter timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread. + /// + /// - Throws: An error while communicating with the Stream API. + public func markUnread(from timestamp: Date) async throws { + guard let channel = await state.channel else { throw ClientError.ChannelNotCreatedYet() } + try await readStateHandler.markUnread(from: .messageTimestamp(timestamp), in: channel) } // MARK: - Message Replies and Pagination diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift index 7b6cf562756..0f1b30a8804 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ChannelReadUpdaterMiddleware.swift @@ -123,7 +123,7 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware { session.markChannelAsUnread( for: cid, userId: userId, - from: messageId, + from: .messageId(messageId), lastReadMessageId: lastReadMessageId, lastReadAt: lastReadAt, unreadMessagesCount: unreadMessages diff --git a/Sources/StreamChat/Workers/ChannelUpdater.swift b/Sources/StreamChat/Workers/ChannelUpdater.swift index 9c8d33b998d..24c1008682d 100644 --- a/Sources/StreamChat/Workers/ChannelUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelUpdater.swift @@ -564,20 +564,20 @@ class ChannelUpdater: Worker { /// - Parameters: /// - cid: The id of the channel to be marked as unread /// - userId: The id of the current user - /// - messageId: The id of the first message id that will be marked as unread. + /// - unreadCriteria: The id or timestamp of the first message that will be marked as unread. /// - lastReadMessageId: The id of the last message that was read. /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. func markUnread( cid: ChannelId, userId: UserId, - from messageId: MessageId, + from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil ) { channelRepository.markUnread( for: cid, userId: userId, - from: messageId, + from: unreadCriteria, lastReadMessageId: lastReadMessageId, completion: completion ) diff --git a/Sources/StreamChat/Workers/ReadStateHandler.swift b/Sources/StreamChat/Workers/ReadStateHandler.swift index 888c9edec20..172b12ac83f 100644 --- a/Sources/StreamChat/Workers/ReadStateHandler.swift +++ b/Sources/StreamChat/Workers/ReadStateHandler.swift @@ -46,7 +46,7 @@ final class ReadStateHandler { } func markUnread( - from messageId: MessageId, + from unreadCriteria: MarkUnreadCriteria, in channel: ChatChannel, completion: @escaping (Result) -> Void ) { @@ -57,13 +57,13 @@ final class ReadStateHandler { return } markingRead = true - messageRepository.getMessage(before: messageId, in: channel.cid) { [weak self] result in + messageRepository.getMessage(before: unreadCriteria, in: channel.cid) { [weak self] result in switch result { case .success(let lastReadMessageId): self?.channelUpdater.markUnread( cid: channel.cid, userId: currentUserId, - from: messageId, + from: unreadCriteria, lastReadMessageId: lastReadMessageId ) { [weak self] result in if case .success = result { @@ -80,12 +80,12 @@ final class ReadStateHandler { } func markUnread( - from messageId: MessageId, + from unreadCriteria: MarkUnreadCriteria, in channel: ChatChannel ) async throws { try await withCheckedThrowingContinuation { continuation in markUnread( - from: messageId, + from: unreadCriteria, in: channel ) { result in continuation.resume(with: result.error) diff --git a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift index 63a5173569c..80e63471599 100644 --- a/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift +++ b/Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift @@ -598,6 +598,10 @@ open class ChatChannelVC: _ViewController, if let event = event as? MessageDeliveredEvent, event.cid == channelController.cid, !messages.isEmpty { messageListVC.listView.reloadRows(at: [.init(item: 0, section: 0)], with: .none) } + + if let event = event as? NotificationMarkUnreadEvent, let channel = channelController.channel, event.cid == channelController.cid, !messages.isEmpty { + updateAllUnreadMessagesRelatedComponents(channel: channel) + } } // MARK: - AudioQueuePlayerDatasource diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 91f358ec03d..ea811b8e779 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -318,6 +318,8 @@ 4F97F27B2BA88936001C4D66 /* MessageSearchState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */; }; 4FB4AB9F2BAD6DBD00712C4E /* Chat_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */; }; 4FBD840B2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */; }; + 4FC7B3F02ED86E3000246903 /* MarkUnreadPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */; }; + 4FC7B3F12ED86E3000246903 /* MarkUnreadPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */; }; 4FCCACE42BC939EB009D23E1 /* MemberList_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */; }; 4FD2BE502B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; }; 4FD2BE512B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; }; @@ -3360,6 +3362,7 @@ 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSearchState+Observer.swift"; sourceTree = ""; }; 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat_Tests.swift; sourceTree = ""; }; 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloader_Spy.swift; sourceTree = ""; }; + 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkUnreadPayload.swift; sourceTree = ""; }; 4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberList_Tests.swift; sourceTree = ""; }; 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateHandler.swift; sourceTree = ""; }; 4FD2BE522B9AEE3500FFC6F2 /* StreamCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCollection.swift; sourceTree = ""; }; @@ -5976,6 +5979,7 @@ 79682C4724BF37550071578E /* Payloads */ = { isa = PBXGroup; children = ( + 4FC7B3EF2ED86E1E00246903 /* MarkUnreadPayload.swift */, AD8513782E9D3013005327C0 /* ChannelDeliveredPayload.swift */, AD25F7422E84667900F16B14 /* PushPreferencePayloads.swift */, ADF047392DE4DADC001C23D2 /* LocationPayloads.swift */, @@ -11911,6 +11915,7 @@ 8413D2E92BDC6300005ADA4E /* PollVoteListQueryDTO.swift in Sources */, DAD539DB250B8A9C00CFC649 /* Controller.swift in Sources */, F6ED5F76250278D7005D7327 /* SyncEndpoint.swift in Sources */, + 4FC7B3F12ED86E3000246903 /* MarkUnreadPayload.swift in Sources */, AD9490572BF3BA9600E69224 /* ThreadListController.swift in Sources */, AD8FEE582AA8E1A100273F88 /* ChatClient+Environment.swift in Sources */, 841BAA512BD1CD81000C73E4 /* PollDTO.swift in Sources */, @@ -13086,6 +13091,7 @@ C121E8E3274544B200023E4C /* Data+Gzip.swift in Sources */, C121E8E4274544B200023E4C /* LazyCachedMapCollection.swift in Sources */, 40789D3D29F6AD9C0018C2BB /* Debouncer.swift in Sources */, + 4FC7B3F02ED86E3000246903 /* MarkUnreadPayload.swift in Sources */, AD7A11CC2DEE091400B8F963 /* LocationEndpoints.swift in Sources */, AD6E32A22BBC50110073831B /* ThreadListQuery.swift in Sources */, ADF0473A2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 7eb26fa3bf4..a133ac60895 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -360,11 +360,11 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.markChannelAsUnread(cid: cid, by: userId) } - func markChannelAsUnread(for cid: ChannelId, userId: UserId, from messageId: MessageId, lastReadMessageId: MessageId?, lastReadAt: Date?, unreadMessagesCount: Int?) { + func markChannelAsUnread(for cid: ChannelId, userId: UserId, from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, lastReadAt: Date?, unreadMessagesCount: Int?) { underlyingSession.markChannelAsUnread( for: cid, userId: userId, - from: messageId, + from: unreadCriteria, lastReadMessageId: lastReadMessageId, lastReadAt: lastReadAt, unreadMessagesCount: unreadMessagesCount diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift index 5e96cd21314..02674bd5d76 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/ChannelRepository_Mock.swift @@ -16,7 +16,7 @@ class ChannelRepository_Mock: ChannelRepository, Spy { var markUnreadCid: ChannelId? var markUnreadUserId: UserId? - var markUnreadMessageId: UserId? + var markUnreadCriteria: MarkUnreadCriteria? var markUnreadLastReadMessageId: UserId? var markUnreadResult: Result? @@ -36,11 +36,11 @@ class ChannelRepository_Mock: ChannelRepository, Spy { } } - override func markUnread(for cid: ChannelId, userId: UserId, from messageId: MessageId, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) { + override func markUnread(for cid: ChannelId, userId: UserId, from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) { record() markUnreadCid = cid markUnreadUserId = userId - markUnreadMessageId = messageId + markUnreadCriteria = unreadCriteria markUnreadLastReadMessageId = lastReadMessageId markUnreadResult.map { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift index 1311233832c..efc10354f85 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelUpdater_Mock.swift @@ -102,7 +102,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { @Atomic var markUnread_cid: ChannelId? @Atomic var markUnread_userId: UserId? - @Atomic var markUnread_messageId: MessageId? + @Atomic var markUnread_criteria: MarkUnreadCriteria? @Atomic var markUnread_lastReadMessageId: MessageId? @Atomic var markUnread_completion: ((Result) -> Void)? @Atomic var markUnread_completion_result: Result? @@ -249,7 +249,7 @@ final class ChannelUpdater_Mock: ChannelUpdater { markUnread_cid = nil markUnread_userId = nil - markUnread_messageId = nil + markUnread_criteria = nil markUnread_lastReadMessageId = nil markUnread_completion = nil markUnread_completion_result = nil @@ -512,10 +512,10 @@ final class ChannelUpdater_Mock: ChannelUpdater { markRead_completion_result?.invoke(with: completion) } - override func markUnread(cid: ChannelId, userId: UserId, from messageId: MessageId, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) { + override func markUnread(cid: ChannelId, userId: UserId, from unreadCriteria: MarkUnreadCriteria, lastReadMessageId: MessageId?, completion: ((Result) -> Void)? = nil) { markUnread_cid = cid markUnread_userId = userId - markUnread_messageId = messageId + markUnread_criteria = unreadCriteria markUnread_lastReadMessageId = lastReadMessageId markUnread_completion = completion markUnread_completion_result?.invoke(with: completion) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift index 1b6f835be3f..dac968915b0 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/ChannelEndpoints_Tests.swift @@ -449,7 +449,7 @@ final class ChannelEndpoints_Tests: XCTestCase { XCTAssertEqual("channels/\(cid.type.rawValue)/\(cid.id)/read", endpoint.path.value) } - func test_markUnread_buildsCorrectly() { + func test_markUnreadWithMessageId_buildsCorrectly() { let cid = ChannelId.unique let messageId = MessageId.unique let userId = UserId.unique @@ -459,13 +459,35 @@ final class ChannelEndpoints_Tests: XCTestCase { method: .post, queryItems: nil, requiresConnectionId: false, - body: [ - "message_id": messageId, - "user_id": userId - ] + body: MarkUnreadPayload( + criteria: .messageId(messageId), + userId: userId + ) + ) + + let endpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageId(messageId), userId: userId)) + + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual(endpoint.path.value, "channels/\(cid.type.rawValue)/\(cid.id)/unread") + } + + func test_markUnreadWithTimestamp_buildsCorrectly() { + let cid = ChannelId.unique + let messageTimestamp = Date.unique + let userId = UserId.unique + + let expectedEndpoint = Endpoint( + path: .markChannelUnread(cid.apiPath), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: MarkUnreadPayload( + criteria: .messageTimestamp(messageTimestamp), + userId: userId + ) ) - let endpoint = Endpoint.markUnread(cid: cid, messageId: messageId, userId: userId) + let endpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageTimestamp(messageTimestamp), userId: userId)) XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) XCTAssertEqual(endpoint.path.value, "channels/\(cid.type.rawValue)/\(cid.id)/unread") diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 37857fb8dd0..2b42fcaaafb 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -4135,7 +4135,7 @@ final class ChannelController_Tests: XCTestCase { func test_markUnread_whenChannelDoesNotExist() { var receivedError: Error? let expectation = self.expectation(description: "Mark Unread completes") - controller.markUnread(from: .unique) { result in + controller.markUnread(from: MessageId.unique) { result in receivedError = result.error expectation.fulfill() } @@ -4156,7 +4156,7 @@ final class ChannelController_Tests: XCTestCase { var receivedError: Error? let expectation = self.expectation(description: "Mark Unread completes") - controller.markUnread(from: .unique) { result in + controller.markUnread(from: MessageId.unique) { result in receivedError = result.error expectation.fulfill() } @@ -4211,7 +4211,7 @@ final class ChannelController_Tests: XCTestCase { var receivedError: Error? let expectation = self.expectation(description: "Mark Unread completes") - controller.markUnread(from: .unique) { result in + controller.markUnread(from: MessageId.unique) { result in receivedError = result.error expectation.fulfill() } @@ -4232,7 +4232,7 @@ final class ChannelController_Tests: XCTestCase { var receivedError: Error? let expectation = self.expectation(description: "Mark Unread completes") - controller.markUnread(from: .unique) { result in + controller.markUnread(from: MessageId.unique) { result in receivedError = result.error expectation.fulfill() } @@ -4255,7 +4255,7 @@ final class ChannelController_Tests: XCTestCase { env.channelUpdater?.markUnread_completion_result = .failure(mockedError) var receivedError: Error? let expectation = self.expectation(description: "Mark Unread completes") - controller.markUnread(from: .unique) { result in + controller.markUnread(from: MessageId.unique) { result in receivedError = result.error expectation.fulfill() } @@ -4291,7 +4291,7 @@ final class ChannelController_Tests: XCTestCase { // Because we don't have other messages, we fallback to the passed messageId as lastReadMessageId. XCTAssertNil(updater.markUnread_lastReadMessageId) - XCTAssertEqual(updater.markUnread_messageId, messageId) + XCTAssertEqual(updater.markUnread_criteria, MarkUnreadCriteria.messageId(messageId)) } func test_markUnread_whenIsNotMarkingAsRead_andCurrentUserIdIsPresent_whenThereAreOtherMessages_whenUpdaterSucceeds() throws { @@ -4324,7 +4324,7 @@ final class ChannelController_Tests: XCTestCase { XCTAssertNil(receivedError) XCTAssertEqual(updater.markUnread_lastReadMessageId, previousMessageId) - XCTAssertEqual(updater.markUnread_messageId, messageId) + XCTAssertEqual(updater.markUnread_criteria, MarkUnreadCriteria.messageId(messageId)) } // MARK: - Load more channel reads diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift index 07243712828..f8dbfb9f6cc 100644 --- a/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift @@ -321,7 +321,23 @@ final class ChannelReadDTO_Tests: XCTestCase { // WHEN try database.writeSynchronously { session in - session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil) + session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil) + } + + // THEN + XCTAssertEqual(database.writeSessionCounter, 1) + XCTAssertNil(readDTO(cid: cid, userId: userId)) + } + + func test_markChannelAsUnreadPartial_whenReadDoesNotExist_messageTimestamp() throws { + // GIVEN + let cid = ChannelId.unique + let userId = UserId.unique + let messageDate = Date() + + // WHEN + try database.writeSynchronously { session in + session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(messageDate), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil) } // THEN @@ -359,7 +375,7 @@ final class ChannelReadDTO_Tests: XCTestCase { // WHEN try database.writeSynchronously { session in - session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil) + session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil) } // THEN @@ -367,6 +383,44 @@ final class ChannelReadDTO_Tests: XCTestCase { XCTAssertNotNil(readDTO(cid: cid, userId: userId)) XCTAssertNil(database.viewContext.message(id: messageId)) } + + func test_markChannelAsUnreadPartial_whenMessageDoesNotExist_messageTimestamp() throws { + // GIVEN + let cid = ChannelId.unique + let userId = UserId.unique + let messageDate = Date() + + let member: MemberPayload = .dummy(user: .dummy(userId: userId)) + let read = ChannelReadPayload( + user: member.user!, + lastReadAt: .init(), + lastReadMessageId: .unique, + unreadMessagesCount: 10, + lastDeliveredAt: nil, + lastDeliveredMessageId: nil + ) + + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: cid), + members: [member], + channelReads: [read] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + database.writeSessionCounter = 0 + + // WHEN + try database.writeSynchronously { session in + session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(messageDate), lastReadMessageId: .unique, lastReadAt: nil, unreadMessagesCount: nil) + } + + // THEN + XCTAssertEqual(database.writeSessionCounter, 1) + XCTAssertNotNil(readDTO(cid: cid, userId: userId)) + } func test_markChannelAsUnreadPartial_whenMessagesExist_shouldUpdateReads() throws { // GIVEN @@ -404,7 +458,7 @@ final class ChannelReadDTO_Tests: XCTestCase { // WHEN try database.writeSynchronously { session in - session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil) + session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil) } // THEN @@ -415,6 +469,52 @@ final class ChannelReadDTO_Tests: XCTestCase { XCTAssertEqual(readDTO.lastReadMessageId, lastReadMessageId) XCTAssertNotNil(database.viewContext.message(id: messageId)) } + + func test_markChannelAsUnreadPartial_whenMessagesExist_shouldUpdateReads_messageTimestamp() throws { + // GIVEN + let cid = ChannelId.unique + let userId = UserId.unique + let lastReadMessageId = MessageId.unique + + let member: MemberPayload = .dummy(user: .dummy(userId: userId)) + let read = ChannelReadPayload( + user: member.user!, + lastReadAt: .init(), + lastReadMessageId: .unique, + unreadMessagesCount: 10, + lastDeliveredAt: nil, + lastDeliveredMessageId: nil + ) + let firstMessageDate = Date() + let messages: [MessagePayload] = [.unique, .unique, .unique].enumerated().map { index, id in + MessagePayload.dummy(messageId: id, authorUserId: .unique, createdAt: firstMessageDate.addingTimeInterval(TimeInterval(index))) + } + + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: cid), + members: [member], + messages: messages, + channelReads: [read] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + database.writeSessionCounter = 0 + + // WHEN - Use the first message's timestamp + try database.writeSynchronously { session in + session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(firstMessageDate), lastReadMessageId: lastReadMessageId, lastReadAt: nil, unreadMessagesCount: nil) + } + + // THEN + XCTAssertEqual(database.writeSessionCounter, 1) + let readDTO = try XCTUnwrap(readDTO(cid: cid, userId: userId)) + XCTAssertNearlySameDate(readDTO.lastReadAt.bridgeDate, firstMessageDate) + XCTAssertEqual(readDTO.unreadMessageCount, 3) + XCTAssertEqual(readDTO.lastReadMessageId, lastReadMessageId) + } func test_markChannelAsUnreadPartial_whenMessagesExist_lastReadAndUnreadMessagesAreSent_shouldUpdateWithArgumentValue() throws { // GIVEN @@ -455,7 +555,7 @@ final class ChannelReadDTO_Tests: XCTestCase { // WHEN try database.writeSynchronously { session in - session.markChannelAsUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: lastReadMessageId, lastReadAt: passedLastReadAt, unreadMessagesCount: passedUnreadMessagesCount) + session.markChannelAsUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: lastReadMessageId, lastReadAt: passedLastReadAt, unreadMessagesCount: passedUnreadMessagesCount) } // THEN @@ -473,6 +573,61 @@ final class ChannelReadDTO_Tests: XCTestCase { XCTAssertNotNil(database.viewContext.message(id: messageId)) } + + func test_markChannelAsUnreadPartial_whenMessagesExist_lastReadAndUnreadMessagesAreSent_shouldUpdateWithArgumentValue_messageTimestamp() throws { + // GIVEN + let cid = ChannelId.unique + let userId = UserId.unique + let lastReadMessageId = MessageId.unique + + let member: MemberPayload = .dummy(user: .dummy(userId: userId)) + let read = ChannelReadPayload( + user: member.user!, + lastReadAt: .init(), + lastReadMessageId: .unique, + unreadMessagesCount: 10, + lastDeliveredAt: nil, + lastDeliveredMessageId: nil + ) + let firstMessageDate = Date() + let messages: [MessagePayload] = [.unique, .unique, .unique].enumerated().map { index, id in + MessagePayload.dummy(messageId: id, authorUserId: .unique, createdAt: firstMessageDate.addingTimeInterval(TimeInterval(index))) + } + + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: cid), + members: [member], + messages: messages, + channelReads: [read] + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + database.writeSessionCounter = 0 + + let passedLastReadAt = Date().addingTimeInterval(-1000) + let passedUnreadMessagesCount = 100 + + // WHEN - Use the first message's timestamp + try database.writeSynchronously { session in + session.markChannelAsUnread(for: cid, userId: userId, from: .messageTimestamp(firstMessageDate), lastReadMessageId: lastReadMessageId, lastReadAt: passedLastReadAt, unreadMessagesCount: passedUnreadMessagesCount) + } + + // THEN + XCTAssertEqual(database.writeSessionCounter, 1) + let readDTO = try XCTUnwrap(readDTO(cid: cid, userId: userId)) + + // Assert pre-calculated values are overridden by argument values + XCTAssertNotEqual(readDTO.lastReadAt.bridgeDate, firstMessageDate) + XCTAssertNotEqual(readDTO.unreadMessageCount, 3) + + // Assert passed values take precedence + XCTAssertNearlySameDate(readDTO.lastReadAt.bridgeDate, passedLastReadAt) + XCTAssertEqual(readDTO.unreadMessageCount, Int32(passedUnreadMessagesCount)) + XCTAssertEqual(readDTO.lastReadMessageId, lastReadMessageId) + } // MARK: - markChannelAsUnread - whole channel diff --git a/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift index 564a993ba45..3923e7bf5cf 100644 --- a/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ChannelRepository_Tests.swift @@ -122,7 +122,7 @@ final class ChannelRepository_Tests: XCTestCase { let expectation = self.expectation(description: "markUnread completes") var receivedError: Error? - repository.markUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique) { result in + repository.markUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique) { result in receivedError = result.error expectation.fulfill() } @@ -130,7 +130,7 @@ final class ChannelRepository_Tests: XCTestCase { apiClient.test_simulateResponse(Result.success(.init())) waitForExpectations(timeout: defaultTimeout) - let referenceEndpoint = Endpoint.markUnread(cid: cid, messageId: messageId, userId: userId) + let referenceEndpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageId(messageId), userId: userId)) XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) XCTAssertEqual(database.writeSessionCounter, 1) XCTAssertNil(receivedError) @@ -143,7 +143,7 @@ final class ChannelRepository_Tests: XCTestCase { let expectation = self.expectation(description: "markUnread completes") var receivedError: Error? - repository.markUnread(for: cid, userId: userId, from: messageId, lastReadMessageId: .unique) { result in + repository.markUnread(for: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: .unique) { result in receivedError = result.error expectation.fulfill() } @@ -153,7 +153,7 @@ final class ChannelRepository_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) - let referenceEndpoint = Endpoint.markUnread(cid: cid, messageId: messageId, userId: userId) + let referenceEndpoint = Endpoint.markUnread(cid: cid, payload: .init(criteria: .messageId(messageId), userId: userId)) XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) XCTAssertEqual(database.writeSessionCounter, 0) XCTAssertEqual(receivedError, error) diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift index 64be1a7a50e..00f316e477f 100644 --- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift @@ -437,7 +437,37 @@ final class MessageRepositoryTests: XCTestCase { ) } let result = try waitFor { done in - repository.getMessage(before: "3", in: cid, completion: done) + repository.getMessage(before: .messageId("3"), in: cid, completion: done) + } + switch result { + case .success(let messageId): + XCTAssertEqual("2", messageId) + case .failure(let error): + XCTFail(error.localizedDescription) + } + } + + func test_getMessageBefore_returnsCorrectResult_messageTimestamp() throws { + let cid = ChannelId.unique + try database.createCurrentUser() + try database.writeSynchronously { session in + let messages = (0..<5).map { index in + MessagePayload.dummy( + messageId: "\(index)", + createdAt: Date(timeIntervalSinceReferenceDate: TimeInterval(index)) + ) + } + try session.saveChannel( + payload: ChannelPayload.dummy( + channel: .dummy(cid: cid), + messages: messages + ) + ) + } + // Use a timestamp between message "2" and "3" to get message "2" + let timestamp = Date(timeIntervalSinceReferenceDate: 2.5) + let result = try waitFor { done in + repository.getMessage(before: .messageTimestamp(timestamp), in: cid, completion: done) } switch result { case .success(let messageId): diff --git a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift index 4f533cc0c18..e26b1f43be2 100644 --- a/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelUpdater_Tests.swift @@ -1730,11 +1730,11 @@ final class ChannelUpdater_Tests: XCTestCase { let messageId = MessageId.unique let lastReadMessageId = MessageId.unique - channelUpdater.markUnread(cid: cid, userId: userId, from: messageId, lastReadMessageId: lastReadMessageId) + channelUpdater.markUnread(cid: cid, userId: userId, from: .messageId(messageId), lastReadMessageId: lastReadMessageId) XCTAssertEqual(channelRepository.markUnreadCid, cid) XCTAssertEqual(channelRepository.markUnreadUserId, userId) - XCTAssertEqual(channelRepository.markUnreadMessageId, messageId) + XCTAssertEqual(channelRepository.markUnreadCriteria, .messageId(messageId)) XCTAssertEqual(channelRepository.markUnreadLastReadMessageId, lastReadMessageId) } @@ -1743,7 +1743,7 @@ final class ChannelUpdater_Tests: XCTestCase { var receivedError: Error? channelRepository.markUnreadResult = .success(.mock(cid: .unique)) - channelUpdater.markUnread(cid: .unique, userId: .unique, from: .unique, lastReadMessageId: .unique) { result in + channelUpdater.markUnread(cid: .unique, userId: .unique, from: .messageId(.unique), lastReadMessageId: .unique) { result in receivedError = result.error expectation.fulfill() } @@ -1758,7 +1758,7 @@ final class ChannelUpdater_Tests: XCTestCase { var receivedError: Error? channelRepository.markUnreadResult = .failure(mockedError) - channelUpdater.markUnread(cid: .unique, userId: .unique, from: .unique, lastReadMessageId: .unique) { result in + channelUpdater.markUnread(cid: .unique, userId: .unique, from: .messageId(.unique), lastReadMessageId: .unique) { result in receivedError = result.error expectation.fulfill() } From 205b37e3241626761b30703c2d2b6afadd68f923 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 28 Nov 2025 11:14:39 +0200 Subject: [PATCH 2/3] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df65e9d60a..297eed0e2e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883) - Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883) - Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883) +- Add `ChatChannelController.markUnread(from:completion:)` and `Chat.markUnread(from:)` where the from argument is `Date` [#3885](https://github.com/GetStream/stream-chat-swift/pull/3885) ### 🐞 Fixed - Fix rare crash in WebSocketClient.connectEndpoint [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882) From 095fb8aa502b52b890d04c8b0011dbbd2a2fa314 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 28 Nov 2025 16:27:28 +0200 Subject: [PATCH 3/3] Add more tests --- .../ChannelController_Tests.swift | 80 +++++++++++++++++++ .../StateLayer/Chat_Tests.swift | 38 +++++++++ 2 files changed, 118 insertions(+) diff --git a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift index 2b42fcaaafb..8422f2152cf 100644 --- a/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelController/ChannelController_Tests.swift @@ -4326,6 +4326,86 @@ final class ChannelController_Tests: XCTestCase { XCTAssertEqual(updater.markUnread_lastReadMessageId, previousMessageId) XCTAssertEqual(updater.markUnread_criteria, MarkUnreadCriteria.messageId(messageId)) } + + func test_markUnread_whenChannelDoesNotExist_messageTimestamp() { + var receivedError: Error? + let expectation = self.expectation(description: "Mark Unread completes") + controller.markUnread(from: Date()) { result in + receivedError = result.error + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertTrue(receivedError is ClientError.ChannelNotCreatedYet) + } + + func test_markUnread_whenReadEventsAreNotEnabled_messageTimestamp() throws { + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: channelId, ownCapabilities: []) + ) + + writeAndWaitForMessageUpdates(count: 0, channelChanges: true) { session in + try session.saveChannel(payload: channel) + } + + var receivedError: Error? + let expectation = self.expectation(description: "Mark Unread completes") + controller.markUnread(from: Date()) { result in + receivedError = result.error + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertTrue(receivedError is ClientError.ChannelFeatureDisabled) + } + + func test_markUnread_whenIsMarkingAsRead_andCurrentUserIdIsPresent_messageTimestamp() throws { + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: channelId, ownCapabilities: [ChannelCapability.readEvents.rawValue]) + ) + + try client.databaseContainer.writeSynchronously { session in + try session.saveChannel(payload: channel) + } + + let currentUserId = UserId.unique + client.setToken(token: .unique(userId: currentUserId)) + try simulateMarkingAsRead(userId: currentUserId) + + var receivedError: Error? + let expectation = self.expectation(description: "Mark Unread completes") + controller.markUnread(from: Date()) { result in + receivedError = result.error + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertNil(receivedError) + } + + func test_markUnread_whenIsNotMarkingAsRead_andCurrentUserIdIsNotPresent_messageTimestamp() throws { + let channel: ChannelPayload = .dummy( + channel: .dummy(cid: channelId, ownCapabilities: [ChannelCapability.readEvents.rawValue]) + ) + + writeAndWaitForMessageUpdates(count: 0, channelChanges: true) { session in + try session.saveChannel(payload: channel) + } + + var receivedError: Error? + let expectation = self.expectation(description: "Mark Unread completes") + controller.markUnread(from: Date()) { result in + receivedError = result.error + expectation.fulfill() + } + + waitForExpectations(timeout: defaultTimeout) + + XCTAssertNil(receivedError) + } // MARK: - Load more channel reads diff --git a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift index 68764b81951..6671f789b4e 100644 --- a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift @@ -1308,6 +1308,44 @@ final class Chat_Tests: XCTestCase { await XCTAssertEqual(3, chat.state.channel?.reads.first?.unreadMessagesCount) } + func test_markUnread_whenAPIRequestSucceeds_thenReadStateUpdates_messageTimestamp() async throws { + try await setUpChat( + usesMockedUpdaters: false, + messageCount: 3 + ) + let messages = await chat.state.messages + let firstMessage = try XCTUnwrap(messages.first) + let lastMessage = try XCTUnwrap(messages.first) + + // Create a read state for the current user + try await env.client.databaseContainer.write { session in + let payload = ChannelPayload.dummy( + channel: .dummy( + cid: self.channelId, + lastMessageAt: lastMessage.createdAt + ), + channelReads: [ + ChannelReadPayload( + user: .dummy(userId: self.currentUserId), + lastReadAt: lastMessage.createdAt, + lastReadMessageId: nil, + unreadMessagesCount: 0, + lastDeliveredAt: nil, + lastDeliveredMessageId: nil + ) + ] + ) + try session.saveChannel(payload: payload) + } + + env.client.mockAPIClient.test_mockResponseResult(.success(EmptyResponse())) + try await chat.markUnread(from: firstMessage.createdAt) + XCTAssertNotNil(env.client.mockAPIClient.request_endpoint) + + await XCTAssertEqual(1, chat.state.channel?.reads.count) + await XCTAssertEqual(3, chat.state.channel?.reads.first?.unreadMessagesCount) + } + // MARK: - Message Replies func test_reply_whenAPIRequestSucceeds_thenStateUpdates() async throws {