diff --git a/Sources/StreamChat/Repositories/ScheduledMessagesRepository.swift b/Sources/StreamChat/Repositories/ScheduledMessagesRepository.swift index 19a5ba72f21..d9f3e6cee4b 100644 --- a/Sources/StreamChat/Repositories/ScheduledMessagesRepository.swift +++ b/Sources/StreamChat/Repositories/ScheduledMessagesRepository.swift @@ -455,6 +455,52 @@ extension ScheduledMessageListQuery { } } +// MARK: - Async + +extension ScheduledMessagesRepository { + func loadScheduledMessages(query: ScheduledMessageListQuery) async throws -> ScheduledMessageListResponse { + try await withCheckedThrowingContinuation { continuation in + loadScheduledMessages(query: query) { continuation.resume(with: $0) } + } + } + + func schedule(id: String, cid: ChannelId, content: ScheduledMessageContent, scheduledAt: Date) async throws -> ScheduledMessage { + try await withCheckedThrowingContinuation { continuation in + schedule(id: id, cid: cid, content: content, scheduledAt: scheduledAt) { continuation.resume(with: $0) } + } + } + + func update(id: String, content: ScheduledMessageContent, scheduledAt: Date?) async throws -> ScheduledMessage { + try await withCheckedThrowingContinuation { continuation in + update(id: id, content: content, scheduledAt: scheduledAt) { continuation.resume(with: $0) } + } + } + + func reschedule(id: String, scheduledAt: Date) async throws -> ScheduledMessage { + try await withCheckedThrowingContinuation { continuation in + reschedule(id: id, scheduledAt: scheduledAt) { continuation.resume(with: $0) } + } + } + + func resend(id: String) async throws -> ScheduledMessage { + try await withCheckedThrowingContinuation { continuation in + resend(id: id) { continuation.resume(with: $0) } + } + } + + func delete(id: String) async throws { + try await withCheckedThrowingContinuation { continuation in + delete(id: id) { continuation.resume(with: $0) } + } + } + + func sendNow(id: String) async throws { + try await withCheckedThrowingContinuation { continuation in + sendNow(id: id) { continuation.resume(with: $0) } + } + } +} + private extension ScheduledMessageFailure.ServerRejection.Reason { /// The reason of the server, or one derived from the status code when the server gives none. init?(apiError: APIError) { diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 05ff3440e69..ab881eda326 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -14,6 +14,7 @@ public class Chat: @unchecked Sendable { private let eventSender: EventSender private let memberUpdater: ChannelMemberUpdater private let messageUpdater: MessageUpdater + private let scheduledMessagesRepository: ScheduledMessagesRepository @MainActor private var stateBuilder: StateBuilder private let typingEventsSender: TypingEventsSender @@ -48,6 +49,10 @@ public class Chat: @unchecked Sendable { client.databaseContainer, client.apiClient ) + scheduledMessagesRepository = environment.scheduledMessagesRepositoryBuilder( + client.databaseContainer, + client.apiClient + ) typingEventsSender = environment.typingEventsSenderBuilder( client.databaseContainer, client.apiClient @@ -1345,7 +1350,192 @@ public class Chat: @unchecked Sendable { try await memberUpdater.pinMemberChannel(false, userId: currentUserId, cid: cid) } } - + + // MARK: - Scheduling Messages + + /// Schedules a new message to be sent to the channel at a later date. + /// + /// The message is not added to the channel until it is sent. It is saved locally first and sent to the server + /// once its attachments are uploaded, see `ScheduledMessage.status`. + /// + /// - SeeAlso: Listing scheduled messages: ``ChatClient/makeScheduledMessageList(with:)``. + /// + /// - Parameters: + /// - text: Text of the message. + /// - attachments: An array of the attachments for the message. + /// - quotedMessageId: An id of the quoted message. + /// - mentions: An array of mentioned user ids. + /// - messageId: A custom id for the scheduled message. Scheduling twice with the same id returns the existing scheduled message. + /// - scheduledAt: When to send the message, at most 120 days from now. The server rounds it up to the next whole minute, + /// at least 1 minute from now. A date in the past sends the message right away. + /// - extraData: Additional extra data of the message object. + /// + /// - Throws: An error while saving the scheduled message locally, not an error of the server. Unlike `sendMessage`, it + /// does not wait for the server: the server answers later, on the scheduled message, and a rejection sets + /// `ScheduledMessage.status` to `.failed`, with the reason in `ScheduledMessage.failure`. + /// - Returns: The scheduled message. + @discardableResult + public func scheduleMessage( + with text: String, + attachments: [AnyAttachmentPayload] = [], + quote quotedMessageId: MessageId? = nil, + mentions: [UserId] = [], + messageId: MessageId? = nil, + scheduledAt: Date, + extraData: [String: RawJSON] = [:] + ) async throws -> ScheduledMessage { + let cid = try await self.cid + let content = ScheduledMessageContent( + text: text, + attachments: attachments, + mentionedUserIds: mentions, + quotedMessageId: quotedMessageId, + parentId: nil, + showReplyInChannel: false, + extraData: extraData + ) + return try await scheduledMessagesRepository.schedule( + id: messageId ?? .newUniqueId, + cid: cid, + content: content, + scheduledAt: scheduledAt + ) + } + + /// Schedules a new reply to the specified message, to be sent at a later date. + /// + /// The reply is not added to the thread until it is sent. It is saved locally first and sent to the server + /// once its attachments are uploaded, see `ScheduledMessage.status`. + /// + /// - Parameters: + /// - parentMessageId: The id of the message to reply to. + /// - text: Text of the reply. + /// - showReplyInChannel: If `true`, the reply is also visible in the channel, not only in the thread. + /// - attachments: An array of the attachments for the reply. + /// - quotedMessageId: An id of the quoted message. + /// - mentions: An array of mentioned user ids. + /// - messageId: A custom id for the scheduled message. Scheduling twice with the same id returns the existing scheduled message. + /// - scheduledAt: When to send the reply, at most 120 days from now. The server rounds it up to the next whole minute, + /// at least 1 minute from now. A date in the past sends the reply right away. + /// - extraData: Additional extra data of the reply. + /// + /// - Throws: An error while saving the scheduled reply locally, not an error of the server. Unlike `sendMessage`, it + /// does not wait for the server: the server answers later, on the scheduled message, and a rejection sets + /// `ScheduledMessage.status` to `.failed`, with the reason in `ScheduledMessage.failure`. + /// - Returns: The scheduled reply. + @discardableResult + public func scheduleReply( + to parentMessageId: MessageId, + text: String, + showReplyInChannel: Bool = false, + attachments: [AnyAttachmentPayload] = [], + quote quotedMessageId: MessageId? = nil, + mentions: [UserId] = [], + messageId: MessageId? = nil, + scheduledAt: Date, + extraData: [String: RawJSON] = [:] + ) async throws -> ScheduledMessage { + let cid = try await self.cid + let content = ScheduledMessageContent( + text: text, + attachments: attachments, + mentionedUserIds: mentions, + quotedMessageId: quotedMessageId, + parentId: parentMessageId, + showReplyInChannel: showReplyInChannel, + extraData: extraData + ) + return try await scheduledMessagesRepository.schedule( + id: messageId ?? .newUniqueId, + cid: cid, + content: content, + scheduledAt: scheduledAt + ) + } + + /// Updates the content of a scheduled message, and optionally when it is sent. + /// + /// A scheduled reply stays in its thread. A failed scheduled message can not be changed: delete it and schedule a new one. + /// The changes are sent to the server once its attachments are uploaded, see `ScheduledMessage.status`. + /// + /// - Parameters: + /// - id: The id of the scheduled message. + /// - text: The new text of the message. + /// - attachments: The new attachments of the message. + /// - quotedMessageId: The new quoted message of the message. + /// - mentions: The new mentioned user ids of the message. + /// - scheduledAt: When to send the message, not in the past. The server rounds it up to the next whole minute. + /// `nil` keeps the current date. + /// - extraData: The new extra data of the message. + /// + /// - Throws: An error while updating the scheduled message locally, not an error of the server. Unlike `sendMessage`, it + /// does not wait for the server: the server answers later, on the scheduled message, and a rejection sets + /// `ScheduledMessage.status` to `.failed`, with the reason in `ScheduledMessage.failure`. + /// - Returns: The updated scheduled message. + @discardableResult + public func updateScheduledMessage( + id: String, + text: String, + attachments: [AnyAttachmentPayload] = [], + quote quotedMessageId: MessageId? = nil, + mentions: [UserId] = [], + scheduledAt: Date? = nil, + extraData: [String: RawJSON] = [:] + ) async throws -> ScheduledMessage { + let content = ScheduledMessageContent( + text: text, + attachments: attachments, + mentionedUserIds: mentions, + quotedMessageId: quotedMessageId, + parentId: nil, + showReplyInChannel: false, + extraData: extraData + ) + return try await scheduledMessagesRepository.update(id: id, content: content, scheduledAt: scheduledAt) + } + + /// Changes when a scheduled message is sent. A failed scheduled message can not be changed: delete it and schedule a new one. + /// + /// The new date is sent to the server right after it is saved locally, see `ScheduledMessage.status`. + /// + /// - Parameters: + /// - id: The id of the scheduled message. + /// - scheduledAt: When to send the message, at most 120 days from now and not in the past. The server rounds it up to the + /// next whole minute, at least 1 minute from now. + /// + /// - Throws: An error while rescheduling the message locally, not an error of the server. Unlike `sendMessage`, it does + /// not wait for the server: the server answers later, on the scheduled message, and a rejection sets + /// `ScheduledMessage.status` to `.failed`, with the reason in `ScheduledMessage.failure`. + /// - Returns: The rescheduled message. + @discardableResult + public func rescheduleMessage(id: String, scheduledAt: Date) async throws -> ScheduledMessage { + try await scheduledMessagesRepository.reschedule(id: id, scheduledAt: scheduledAt) + } + + /// Deletes a scheduled message, so it is never sent. + /// + /// - Parameter id: The id of the scheduled message. + /// + /// - Throws: An error while deleting the scheduled message. + public func deleteScheduledMessage(id: String) async throws { + try await scheduledMessagesRepository.delete(id: id) + } + + /// Sends a scheduled message right away. + /// + /// It is marked locally and sent once its local changes and attachments are on the server, so it is never sent + /// with older content. The message is added to the channel when it is sent, which is reported by the + /// `ScheduledMessageSentEvent`. + /// + /// - Parameter id: The id of the scheduled message. + /// + /// - Throws: An error when the message can't be sent, for example when its quoted message was deleted. It does not + /// wait for the server: a rejection sets `ScheduledMessage.status` to `.failed`, with the reason in + /// `ScheduledMessage.failure`. + public func sendScheduledMessageNow(id: String) async throws { + try await scheduledMessagesRepository.sendNow(id: id) + } + // MARK: - Sending and Listening to Events /// Subscribes to web-socket events of a single type which is a channel specific event in this channel. @@ -1770,6 +1960,11 @@ extension Chat { ) } + var scheduledMessagesRepositoryBuilder: @Sendable ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> ScheduledMessagesRepository = { ScheduledMessagesRepository(database: $0, apiClient: $1) } + var typingEventsSenderBuilder: @Sendable ( _ database: DatabaseContainer, _ apiClient: APIClient diff --git a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift index 7c678b1252a..9656337fe73 100644 --- a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift +++ b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift @@ -364,6 +364,22 @@ extension ChatClient { } } +// MARK: - Factory Methods for Creating Scheduled Message Lists + +extension ChatClient { + /// Creates an instance of ``ScheduledMessageList`` which represents the current user's scheduled messages matching to the specified ``ScheduledMessageListQuery``. + /// + /// Loaded scheduled messages are stored in ``ScheduledMessageListState/scheduledMessages`` and kept up to date with local changes and events. + /// Call ``ScheduledMessageList/get()`` for loading the first page. + /// + /// - Parameter query: The query which filters the scheduled messages, for example by channel or status. + /// + /// - Returns: An instance of ``ScheduledMessageList`` which represents actions and the current state of the list. + public func makeScheduledMessageList(with query: ScheduledMessageListQuery = .init()) -> ScheduledMessageList { + ScheduledMessageList(query: query, client: self) + } +} + // MARK: - Factory Methods for Creating User Lists extension ChatClient { diff --git a/Sources/StreamChat/StateLayer/ScheduledMessageList.swift b/Sources/StreamChat/StateLayer/ScheduledMessageList.swift new file mode 100644 index 00000000000..8850d23882c --- /dev/null +++ b/Sources/StreamChat/StateLayer/ScheduledMessageList.swift @@ -0,0 +1,135 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// An object which represents the current user's list of `ScheduledMessage` for the specified query. +public final class ScheduledMessageList: Sendable { + private let query: ScheduledMessageListQuery + private let scheduledMessagesRepository: ScheduledMessagesRepository + @MainActor private var stateBuilder: StateBuilder + + init(query: ScheduledMessageListQuery, client: ChatClient, environment: Environment = .init()) { + self.query = query + scheduledMessagesRepository = environment.scheduledMessagesRepositoryBuilder( + client.databaseContainer, + client.apiClient + ) + stateBuilder = StateBuilder { + environment.stateBuilder( + query, + client.databaseContainer + ) + } + } + + // MARK: - Accessing the State + + /// An observable object representing the current state of the scheduled message list. + @MainActor public var state: ScheduledMessageListState { stateBuilder.state } + + /// Fetches the most recent state from the server and updates the local store. + /// + /// - Throws: An error while communicating with the Stream API. + public func get() async throws { + try await loadScheduledMessages(cursor: nil, limit: query.pagination.pageSize) + } + + // MARK: - Scheduled Messages Pagination + + /// Loads more scheduled messages and updates ``ScheduledMessageListState/scheduledMessages``. + /// + /// - Parameter limit: The limit for the page size. The default limit is the page size of the query. + /// + /// - Throws: An error while communicating with the Stream API. + /// - Returns: An array of loaded scheduled messages, empty when all of them are already loaded. + @discardableResult public func loadMoreScheduledMessages(limit: Int? = nil) async throws -> [ScheduledMessage] { + guard await !state.hasLoadedAllScheduledMessages else { return [] } + let cursor = await state.nextCursor + return try await loadScheduledMessages(cursor: cursor, limit: limit ?? query.pagination.pageSize) + } + + // MARK: - Managing Scheduled Messages + + /// Changes when a scheduled message is sent. A failed scheduled message can not be changed: delete it and schedule a new one. + /// + /// The new date is sent to the server right after it is saved locally, see `ScheduledMessage.status`. + /// + /// - Parameters: + /// - id: The id of the scheduled message. + /// - scheduledAt: When to send the message, at most 120 days from now and not in the past. The server rounds it up to the + /// next whole minute, at least 1 minute from now. + /// + /// - Throws: An error while rescheduling the message locally, not an error of the server. Unlike `sendMessage`, it does + /// not wait for the server: the server answers later, on the scheduled message, and a rejection sets + /// `ScheduledMessage.status` to `.failed`, with the reason in `ScheduledMessage.failure`. + /// - Returns: The rescheduled message. + @discardableResult public func rescheduleMessage(id: String, scheduledAt: Date) async throws -> ScheduledMessage { + try await scheduledMessagesRepository.reschedule(id: id, scheduledAt: scheduledAt) + } + + /// Deletes a scheduled message, so it is never sent. + /// + /// - Parameter id: The id of the scheduled message. + /// + /// - Throws: An error while deleting the scheduled message. + public func deleteScheduledMessage(id: String) async throws { + try await scheduledMessagesRepository.delete(id: id) + } + + /// Sends a scheduled message right away. + /// + /// It is marked locally and sent once its local changes and attachments are on the server, so it is never sent + /// with older content. The message is added to the channel when it is sent, which is reported by the + /// `ScheduledMessageSentEvent`. + /// + /// - Parameter id: The id of the scheduled message. + /// + /// - Throws: An error when the message can't be sent, for example when its quoted message was deleted. It does not + /// wait for the server: a rejection sets `ScheduledMessage.status` to `.failed`, with the reason in + /// `ScheduledMessage.failure`. + public func sendScheduledMessageNow(id: String) async throws { + try await scheduledMessagesRepository.sendNow(id: id) + } + + /// Sends the changes of a scheduled message that failed to reach the server again. + /// + /// A scheduled message is saved locally first and its `status` is `.failed` when it could not be scheduled + /// or updated on the server. + /// + /// - Parameter id: The id of the scheduled message. + /// + /// - Throws: An error when the scheduled message has no failed changes, not an error of the server. It does not wait for + /// the server: the server answers later, on the scheduled message, and a rejection sets `ScheduledMessage.status` to + /// `.failed` again, with the reason in `ScheduledMessage.failure`. + public func resendScheduledMessage(id: String) async throws { + _ = try await scheduledMessagesRepository.resend(id: id) + } + + // MARK: - Private + + @discardableResult private func loadScheduledMessages(cursor: String?, limit: Int) async throws -> [ScheduledMessage] { + var query = query + query.pagination = Pagination(pageSize: limit, cursor: cursor) + let response = try await scheduledMessagesRepository.loadScheduledMessages(query: query) + await state.setNextCursor(response.next) + return response.scheduledMessages + } +} + +extension ScheduledMessageList { + struct Environment: Sendable { + var scheduledMessagesRepositoryBuilder: @Sendable ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> ScheduledMessagesRepository = { ScheduledMessagesRepository(database: $0, apiClient: $1) } + + var stateBuilder: @Sendable @MainActor ( + _ query: ScheduledMessageListQuery, + _ database: DatabaseContainer + ) -> ScheduledMessageListState = { @MainActor in + ScheduledMessageListState(query: $0, database: $1) + } + } +} diff --git a/Sources/StreamChat/StateLayer/ScheduledMessageListState+Observer.swift b/Sources/StreamChat/StateLayer/ScheduledMessageListState+Observer.swift new file mode 100644 index 00000000000..bfb00f4dc0b --- /dev/null +++ b/Sources/StreamChat/StateLayer/ScheduledMessageListState+Observer.swift @@ -0,0 +1,33 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension ScheduledMessageListState { + struct Observer { + private let scheduledMessageListObserver: StateLayerDatabaseObserver + + init(query: ScheduledMessageListQuery, database: DatabaseContainer) { + scheduledMessageListObserver = StateLayerDatabaseObserver( + database: database, + fetchRequest: ScheduledMessageDTO.scheduledMessagesFetchRequest(query: query), + itemCreator: { try $0.asModel() }, + itemReuseKeyPaths: (\ScheduledMessage.id, \ScheduledMessageDTO.id) + ) + } + + struct Handlers { + let scheduledMessagesDidChange: @Sendable @MainActor ([ScheduledMessage]) async -> Void + } + + func start(with handlers: Handlers) -> [ScheduledMessage] { + do { + return try scheduledMessageListObserver.startObserving(didChange: handlers.scheduledMessagesDidChange) + } catch { + log.error("Failed to start the scheduled message list observer with error \(error)") + return [] + } + } + } +} diff --git a/Sources/StreamChat/StateLayer/ScheduledMessageListState.swift b/Sources/StreamChat/StateLayer/ScheduledMessageListState.swift new file mode 100644 index 00000000000..139238cafb0 --- /dev/null +++ b/Sources/StreamChat/StateLayer/ScheduledMessageListState.swift @@ -0,0 +1,36 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +/// Represents the current user's list of scheduled messages matching to the specified query. +@MainActor public final class ScheduledMessageListState: ObservableObject { + private let observer: Observer + private(set) var nextCursor: String? + + init(query: ScheduledMessageListQuery, database: DatabaseContainer) { + self.query = query + observer = Observer(query: query, database: database) + scheduledMessages = observer.start( + with: .init(scheduledMessagesDidChange: { [weak self] in self?.scheduledMessages = $0 }) + ) + } + + /// The query specifying and filtering the list of scheduled messages. + public let query: ScheduledMessageListQuery + + /// An array of scheduled messages for the specified ``ScheduledMessageListQuery``. + /// + /// Pending scheduled messages come first, then the failed ones, each sorted by the date they are sent. + @Published public private(set) var scheduledMessages: [ScheduledMessage] = [] + + /// A Boolean value that returns whether all the scheduled messages are loaded. + @Published public private(set) var hasLoadedAllScheduledMessages = false + + func setNextCursor(_ cursor: String?) { + nextCursor = cursor + hasLoadedAllScheduledMessages = cursor == nil + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index df1806ac6ef..2e9a60eb668 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1044,6 +1044,7 @@ StreamChatTests/StateLayer/ReactionList_Tests.swift, StreamChatTests/StateLayer/RoleSearch_Tests.swift, StreamChatTests/StateLayer/RoleSearchState_Tests.swift, + StreamChatTests/StateLayer/ScheduledMessageList_Tests.swift, StreamChatTests/StateLayer/StateLayerDatabaseObserver_Tests.swift, StreamChatTests/StateLayer/UserGroupSearch_Tests.swift, StreamChatTests/StateLayer/UserGroupSearchState_Tests.swift, diff --git a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift index fdeead28382..b3a1ac43aef 100644 --- a/Tests/StreamChatTests/StateLayer/Chat_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/Chat_Tests.swift @@ -2102,7 +2102,76 @@ final class Chat_Tests: XCTestCase { XCTAssertEqual(hardDelete, env.channelUpdaterMock.truncateChannel_hardDelete) XCTAssertEqual(systemMessage, env.channelUpdaterMock.truncateChannel_systemMessage) } - + + // MARK: - Scheduling Messages + + func test_scheduleMessage_thenTheScheduledMessageIsSavedLocally() async throws { + let scheduledAt = Date().addingTimeInterval(3600) + + let scheduledMessage = try await chat.scheduleMessage(with: "Later", messageId: "scheduled-id", scheduledAt: scheduledAt) + + XCTAssertEqual("scheduled-id", scheduledMessage.id) + XCTAssertEqual(channelId, scheduledMessage.cid) + XCTAssertEqual("Later", scheduledMessage.message.text) + XCTAssertEqual(nil, scheduledMessage.message.parentMessageId) + XCTAssertEqual(scheduledAt.timeIntervalSince1970, scheduledMessage.scheduledAt.timeIntervalSince1970, accuracy: 0.001) + XCTAssertEqual(.preparingToSchedule, scheduledMessage.status) + let isStored = try env.client.databaseContainer.readSynchronously { $0.scheduledMessage(id: "scheduled-id") != nil } + XCTAssertTrue(isStored) + } + + func test_scheduleReply_thenTheScheduledReplyIsInTheThread() async throws { + let parentMessageId = MessageId.unique + + let scheduledMessage = try await chat.scheduleReply( + to: parentMessageId, + text: "Later", + showReplyInChannel: true, + scheduledAt: Date().addingTimeInterval(3600) + ) + + XCTAssertEqual(parentMessageId, scheduledMessage.message.parentMessageId) + XCTAssertEqual(true, scheduledMessage.message.showReplyInChannel) + } + + func test_updateScheduledMessage_thenTheContentIsUpdatedLocally() async throws { + let scheduledMessage = try await chat.scheduleMessage(with: "Later", scheduledAt: Date().addingTimeInterval(3600)) + + let updated = try await chat.updateScheduledMessage(id: scheduledMessage.id, text: "Edited") + + XCTAssertEqual("Edited", updated.message.text) + XCTAssertEqual(scheduledMessage.scheduledAt, updated.scheduledAt) + } + + func test_rescheduleMessage_thenTheDateIsUpdatedLocally() async throws { + let scheduledMessage = try await chat.scheduleMessage(with: "Later", scheduledAt: Date().addingTimeInterval(3600)) + let newDate = Date().addingTimeInterval(7200) + + let rescheduled = try await chat.rescheduleMessage(id: scheduledMessage.id, scheduledAt: newDate) + + XCTAssertEqual(newDate.timeIntervalSince1970, rescheduled.scheduledAt.timeIntervalSince1970, accuracy: 0.001) + } + + func test_deleteScheduledMessage_whenItIsNotOnTheServer_thenItIsDeletedLocally() async throws { + let scheduledMessage = try await chat.scheduleMessage(with: "Later", scheduledAt: Date().addingTimeInterval(3600)) + + try await chat.deleteScheduledMessage(id: scheduledMessage.id) + + let isStored = try env.client.databaseContainer.readSynchronously { $0.scheduledMessage(id: scheduledMessage.id) != nil } + XCTAssertFalse(isStored) + } + + func test_sendScheduledMessageNow_thenItIsMarkedToBeSent() async throws { + let scheduledMessage = try await chat.scheduleMessage(with: "Later", scheduledAt: Date().addingTimeInterval(3600)) + + try await chat.sendScheduledMessageNow(id: scheduledMessage.id) + + let isPendingSendNow = try env.client.databaseContainer.readSynchronously { + $0.scheduledMessage(id: scheduledMessage.id)?.isPendingSendNow + } + XCTAssertEqual(true, isPendingSendNow) + } + // MARK: - Test Data /// Configures chat for testing. diff --git a/Tests/StreamChatTests/StateLayer/ScheduledMessageList_Tests.swift b/Tests/StreamChatTests/StateLayer/ScheduledMessageList_Tests.swift new file mode 100644 index 00000000000..8f0ae5b2bdc --- /dev/null +++ b/Tests/StreamChatTests/StateLayer/ScheduledMessageList_Tests.swift @@ -0,0 +1,261 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ScheduledMessageList_Tests: XCTestCase { + private var client: ChatClient_Mock! + private var apiClient: APIClient_Spy! + private var cid: ChannelId! + private var scheduledMessageList: ScheduledMessageList! + + override func setUpWithError() throws { + client = ChatClient_Mock(config: ChatClient_Mock.defaultMockedConfig) + apiClient = client.mockAPIClient + cid = .unique + let userId = UserId.unique + client.currentUserId_mock = userId + try client.mockDatabaseContainer.createCurrentUser(id: userId) + } + + override func tearDownWithError() throws { + apiClient.cleanUp() + client.cleanUp() + apiClient = nil + client = nil + cid = nil + scheduledMessageList = nil + } + + // MARK: - Restoring State + + @MainActor func test_restoreState_whenDatabaseHasScheduledMessages_thenStateIsUpToDate() async throws { + let message = try await schedule() + + setUpScheduledMessageList() + + XCTAssertEqual([message.id], scheduledMessageList.state.scheduledMessages.map(\.id)) + } + + @MainActor func test_restoreState_onlyIncludesTheScheduledMessagesMatchingTheQuery() async throws { + let message = try await schedule() + _ = try await schedule(cid: .unique) + + setUpScheduledMessageList(query: .init(filter: .equal(.cid, to: cid))) + + XCTAssertEqual([message.id], scheduledMessageList.state.scheduledMessages.map(\.id)) + } + + // MARK: - Get + + @MainActor func test_get_thenStateHasTheLoadedScheduledMessages() async throws { + setUpScheduledMessageList() + apiClient.test_mockResponseResult(.success(QueryScheduledMessagesResponse(scheduledMessages: [.dummy(id: "first", cid: cid)]))) + + try await scheduledMessageList.get() + + XCTAssertEqual(["first"], scheduledMessageList.state.scheduledMessages.map(\.id)) + XCTAssertTrue(scheduledMessageList.state.hasLoadedAllScheduledMessages) + } + + @MainActor func test_get_whenThereIsANextPage_thenNotAllScheduledMessagesAreLoaded() async throws { + setUpScheduledMessageList() + apiClient.test_mockResponseResult(.success(QueryScheduledMessagesResponse(next: "cursor", scheduledMessages: [.dummy(cid: cid)]))) + + try await scheduledMessageList.get() + + XCTAssertFalse(scheduledMessageList.state.hasLoadedAllScheduledMessages) + } + + @MainActor func test_get_whenTheRequestFails_thenGetFails() async throws { + setUpScheduledMessageList() + apiClient.test_mockResponseResult(Result.failure(ClientError.ConnectionError())) + + await XCTAssertAsyncFailure(try await scheduledMessageList.get(), expectedErrorHandler: { $0 is ClientError.ConnectionError }) + } + + // MARK: - Pagination + + @MainActor func test_loadMoreScheduledMessages_thenTheNextPageIsRequestedAndAddedToTheState() async throws { + setUpScheduledMessageList(query: .init(pageSize: 1)) + apiClient.test_mockResponseResult(.success(QueryScheduledMessagesResponse(next: "cursor", scheduledMessages: [.dummy(id: "first", cid: cid)]))) + try await scheduledMessageList.get() + apiClient.test_mockResponseResult(.success(QueryScheduledMessagesResponse(scheduledMessages: [.dummy(id: "second", cid: cid)]))) + + let loaded = try await scheduledMessageList.loadMoreScheduledMessages() + + let expectedEndpoint: Endpoint = .queryScheduledMessages( + queryScheduledMessagesRequest: QueryScheduledMessagesRequest(limit: 1, next: "cursor") + ) + XCTAssertEqual(AnyEndpoint(expectedEndpoint), apiClient.request_endpoint) + XCTAssertEqual(["second"], loaded.map(\.id)) + XCTAssertTrue(scheduledMessageList.state.hasLoadedAllScheduledMessages) + XCTAssertEqual(["first", "second"], Set(scheduledMessageList.state.scheduledMessages.map(\.id))) + } + + @MainActor func test_loadMoreScheduledMessages_whenAllAreLoaded_thenNothingIsRequested() async throws { + setUpScheduledMessageList() + apiClient.test_mockResponseResult(.success(QueryScheduledMessagesResponse(scheduledMessages: [.dummy(id: "first", cid: cid)]))) + try await scheduledMessageList.get() + + let loaded = try await scheduledMessageList.loadMoreScheduledMessages() + + XCTAssertEqual([], loaded.map(\.id)) + XCTAssertEqual(1, apiClient.request_allRecordedCalls.count) + XCTAssertEqual(["first"], scheduledMessageList.state.scheduledMessages.map(\.id)) + } + + // MARK: - Managing Scheduled Messages + + @MainActor func test_rescheduleMessage_thenTheStateHasTheNewDate() async throws { + let message = try await schedule() + setUpScheduledMessageList() + let newDate = Date().addingTimeInterval(7200) + + let rescheduled = try await scheduledMessageList.rescheduleMessage(id: message.id, scheduledAt: newDate) + + XCTAssertEqual(newDate.timeIntervalSince1970, rescheduled.scheduledAt.timeIntervalSince1970, accuracy: 0.001) + let stored = try await waitForScheduledMessage(id: message.id) { + abs($0.scheduledAt.timeIntervalSince1970 - newDate.timeIntervalSince1970) < 0.001 + } + XCTAssertEqual(newDate.timeIntervalSince1970, stored.scheduledAt.timeIntervalSince1970, accuracy: 0.001) + } + + @MainActor func test_deleteScheduledMessage_thenItIsRemovedFromTheState() async throws { + let message = try await schedule() + setUpScheduledMessageList() + apiClient.test_mockResponseResult(.success( + DeleteScheduledMessageResponse(scheduledMessage: .dummy(id: message.id, cid: cid, status: .canceled)) + )) + + try await scheduledMessageList.deleteScheduledMessage(id: message.id) + + try await waitUntil { self.scheduledMessageList.state.scheduledMessages.isEmpty } + XCTAssertEqual([], scheduledMessageList.state.scheduledMessages.map(\.id)) + } + + @MainActor func test_sendScheduledMessageNow_thenItIsMarkedUntilTheSchedulerSendsIt() async throws { + let message = try await schedule() + setUpScheduledMessageList() + + try await scheduledMessageList.sendScheduledMessageNow(id: message.id) + + XCTAssertNil(apiClient.request_endpoint) + let stored = try await waitForScheduledMessage(id: message.id) { $0.status == .preparingToSchedule } + XCTAssertEqual(.preparingToSchedule, stored.status) + } + + @MainActor func test_resendScheduledMessage_whenItHasNoFailedChanges_thenResendFails() async throws { + let message = try await schedule() + setUpScheduledMessageList() + + await XCTAssertAsyncFailure( + try await scheduledMessageList.resendScheduledMessage(id: message.id), + expectedErrorHandler: { $0 is ClientError.ScheduledMessageHasNoPendingChanges } + ) + } + + @MainActor func test_resendScheduledMessage_whenItDoesNotExist_thenResendFails() async throws { + setUpScheduledMessageList() + + await XCTAssertAsyncFailure( + try await scheduledMessageList.resendScheduledMessage(id: .unique), + expectedErrorHandler: { $0 is ClientError.ScheduledMessageDoesNotExist } + ) + } + + // MARK: - Content Changes + + // The list reuses scheduled messages by id, so these check that changes of their content still reach it. + + @MainActor func test_scheduledMessages_whenTheContentChanges_thenTheStateIsUpdated() async throws { + let message = try await schedule() + setUpScheduledMessageList() + + try await client.databaseContainer.write { session in + let content = try XCTUnwrap(session.scheduledMessage(id: message.id)?.message) + content.text = "Edited" + } + + let stored = try await waitForScheduledMessage(id: message.id) { $0.message.text == "Edited" } + XCTAssertEqual("Edited", stored.message.text) + } + + @MainActor func test_scheduledMessages_whenAnAttachmentChanges_thenTheStateIsUpdated() async throws { + let message = try await scheduleLocally(attachments: [.mockImage]) + setUpScheduledMessageList() + + // Only the attachment changes, not the content message or the scheduled message. + try await client.databaseContainer.write { session in + let content = try XCTUnwrap(session.scheduledMessage(id: message.id)?.message) + content.attachments.forEach { $0.localState = .uploadingFailed } + } + + let stored = try await waitForScheduledMessage(id: message.id) { + $0.message.allAttachments.first?.uploadingState?.state == .uploadingFailed + } + XCTAssertEqual(.uploadingFailed, stored.message.allAttachments.first?.uploadingState?.state) + } + + // MARK: - Test Data + + @MainActor private func setUpScheduledMessageList(query: ScheduledMessageListQuery = .init()) { + scheduledMessageList = client.makeScheduledMessageList(with: query) + _ = scheduledMessageList.state + } + + /// Saves a scheduled message as returned by the server. + private func schedule(cid: ChannelId? = nil) async throws -> ScheduledMessage { + let payload = ScheduledMessageResponse.dummy(cid: cid ?? self.cid!) + return try await withCheckedThrowingContinuation { continuation in + client.databaseContainer.write(converting: { session in + try session.saveScheduledMessage(payload: payload).asModel() + }, completion: { continuation.resume(with: $0) }) + } + } + + /// Schedules a message on the device, with local attachments. + @MainActor private func scheduleLocally(attachments: [AnyAttachmentPayload]) async throws -> ScheduledMessage { + apiClient.test_mockResponseResult(.success( + SendMessageResponsePayload(message: .dummy(cid: cid), scheduledMessage: .dummy(cid: cid)) + )) + let channelController = client.channelController(for: cid) + return try await withCheckedThrowingContinuation { continuation in + channelController.scheduleMessage( + text: "Photo", + attachments: attachments, + scheduledAt: Date().addingTimeInterval(3600), + completion: { continuation.resume(with: $0) } + ) + } + } + + @MainActor private func waitForScheduledMessage( + id: String, + matching predicate: (ScheduledMessage) -> Bool + ) async throws -> ScheduledMessage { + try await waitUntil { + self.scheduledMessageList.state.scheduledMessages.contains { $0.id == id && predicate($0) } + } + return try XCTUnwrap(scheduledMessageList.state.scheduledMessages.first { $0.id == id }) + } + + @MainActor private func waitUntil( + timeout: TimeInterval = 2, + _ condition: () throws -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while try !condition() { + guard Date() < deadline else { + XCTFail("The condition was not met in time", file: file, line: line) + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } +}