From 0830c0a690a1c7cce3d53199a0d92559f72f95ea Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Mar 2025 16:39:25 +0000 Subject: [PATCH 01/42] Add new reminder endpoint paths --- .../Endpoints/EndpointPath+OfflineRequest.swift | 2 +- .../StreamChat/APIClient/Endpoints/EndpointPath.swift | 9 ++++++++- .../APIClient/Endpoints/EndpointPath_Tests.swift | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift index 232ab780388..8aa9624a78b 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath+OfflineRequest.swift @@ -15,7 +15,7 @@ extension EndpointPath { .replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage, .callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, .polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote, - .unread, .blockUser, .unblockUser, .drafts: + .unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder: return false } } diff --git a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift index 8a31cc30a76..66f902cd124 100644 --- a/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift +++ b/Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift @@ -55,6 +55,10 @@ enum EndpointPath: Codable { case drafts case draftMessage(ChannelId) + // Reminders + case reminders + case reminder(MessageId) + case banMember case flagUser(Bool) case flagMessage(Bool) @@ -135,6 +139,9 @@ enum EndpointPath: Codable { case .drafts: return "drafts/query" case let .draftMessage(channelId): return "channels/\(channelId.apiPath)/draft" + case .reminders: return "reminders/query" + case let .reminder(messageId): return "messages/\(messageId)/reminders" + case .banMember: return "moderation/ban" case let .flagUser(flag): return "moderation/\(flag ? "flag" : "unflag")" case let .flagMessage(flag): return "moderation/\(flag ? "flag" : "unflag")" @@ -151,9 +158,9 @@ enum EndpointPath: Codable { case let .poll(pollId: pollId): return "polls/\(pollId)" case let .pollOption(pollId: pollId, optionId: optionId): return "polls/\(pollId)/options/\(optionId)" case let .pollOptions(pollId: pollId): return "polls/\(pollId)/options" + case let .pollVotes(pollId: pollId): return "polls/\(pollId)/votes" case let .pollVoteInMessage(messageId: messageId, pollId: pollId): return "messages/\(messageId)/polls/\(pollId)/vote" case let .pollVote(messageId: messageId, pollId: pollId, voteId: voteId): return "messages/\(messageId)/polls/\(pollId)/vote/\(voteId)" - case let .pollVotes(pollId: pollId): return "polls/\(pollId)/votes" } } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift index 852df46419c..5993c05233b 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/EndpointPath_Tests.swift @@ -66,6 +66,11 @@ final class EndpointPathTests: XCTestCase { XCTAssertFalse(EndpointPath.pollVote(messageId: "test_message", pollId: "test_poll", voteId: "test_vote").shouldBeQueuedOffline) } + func test_reminders_shouldNOTBeQueuedOffline() { + XCTAssertFalse(EndpointPath.reminders.shouldBeQueuedOffline) + XCTAssertFalse(EndpointPath.reminder("test_message").shouldBeQueuedOffline) + } + func test_unread_shouldNOTBeQueuedOffline() { XCTAssertFalse(EndpointPath.unread.shouldBeQueuedOffline) } @@ -146,6 +151,9 @@ final class EndpointPathTests: XCTestCase { assertResultEncodingAndDecoding(.drafts) assertResultEncodingAndDecoding(.draftMessage(ChannelId(type: .messaging, id: "test_channel"))) + + assertResultEncodingAndDecoding(.reminders) + assertResultEncodingAndDecoding(.reminder("test_message")) } } From 3f8991a9a30a4b54da5fba89b271ea3be09a329d Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Mar 2025 16:46:08 +0000 Subject: [PATCH 02/42] Add new Message Reminder Endpoints --- .../Endpoints/MessageEndpoints.swift | 53 +++++++++ .../Endpoints/Payloads/MessagePayloads.swift | 82 +++++++++++++- .../Endpoints/MessageEndpoints_Tests.swift | 101 ++++++++++++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index e0a705226e2..0d784d93ed5 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -94,6 +94,59 @@ extension Endpoint { } } +// MARK: - Reminder Endpoints + +extension Endpoint { + // Creates or updates a reminder for a message + static func createReminder(messageId: MessageId, request: ReminderRequestBody) -> Endpoint { + .init( + path: .reminder(messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + } + + // Updates an existing reminder for a message + static func updateReminder(messageId: MessageId, request: ReminderRequestBody) -> Endpoint { + .init( + path: .reminder(messageId), + method: .patch, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + } + + // Deletes a reminder for a message + static func deleteReminder(messageId: MessageId, userId: UserId? = nil) -> Endpoint { + var body: [String: AnyEncodable]? + if let userId = userId { + body = ["user_id": AnyEncodable(userId)] + } + + return .init( + path: .reminder(messageId), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: body + ) + } + + // Queries reminders with the provided parameters + static func queryReminders(query: MessageReminderListQuery) -> Endpoint { + .init( + path: .reminders, + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: query + ) + } +} + // MARK: - Helper data structures struct MessagePartialUpdateRequest: Encodable { diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 880be5f98ac..48e6506e631 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -54,6 +54,7 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable { case skipEnrichUrl = "skip_enrich_url" case restrictedVisibility = "restricted_visibility" case draft + case reminder } extension MessagePayload { @@ -111,8 +112,8 @@ class MessagePayload: Decodable { var pinExpires: Date? var poll: PollPayload? - var draft: DraftPayload? + var reminder: ReminderPayload? /// Only message payload from `getMessage` endpoint contains channel data. It's a convenience workaround for having to /// make an extra call do get channel details. @@ -179,6 +180,7 @@ class MessagePayload: Decodable { messageTextUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .messageTextUpdatedAt) poll = try container.decodeIfPresent(PollPayload.self, forKey: .poll) draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) + reminder = try container.decodeIfPresent(ReminderPayload.self, forKey: .reminder) } init( @@ -219,7 +221,8 @@ class MessagePayload: Decodable { moderationDetails: MessageModerationDetailsPayload? = nil, messageTextUpdatedAt: Date? = nil, poll: PollPayload? = nil, - draft: DraftPayload? = nil + draft: DraftPayload? = nil, + reminder: ReminderPayload? = nil ) { self.id = id self.cid = cid @@ -259,6 +262,7 @@ class MessagePayload: Decodable { self.messageTextUpdatedAt = messageTextUpdatedAt self.poll = poll self.draft = draft + self.reminder = reminder } } @@ -382,3 +386,77 @@ public struct Command: Codable, Hashable { self.args = args } } + +/// An object describing a reminder JSON payload. +struct ReminderPayload: Decodable { + let userId: UserId + let user: UserPayload? + let channelCid: ChannelId + let messageId: MessageId + let message: MessagePayload? + let remindAt: Date? + let createdAt: Date + let updatedAt: Date + + init( + userId: UserId, + user: UserPayload? = nil, + channelCid: ChannelId, + messageId: MessageId, + message: MessagePayload? = nil, + remindAt: Date?, + createdAt: Date, + updatedAt: Date + ) { + self.userId = userId + self.user = user + self.channelCid = channelCid + self.messageId = messageId + self.message = message + self.remindAt = remindAt + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case user + case channelCid = "channel_cid" + case messageId = "message_id" + case message + case remindAt = "remind_at" + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +/// A request body for creating or updating a reminder +struct ReminderRequestBody: Encodable { + let remindAt: Date? + let userId: UserId? + + init( + remindAt: Date?, + userId: UserId? = nil + ) { + self.remindAt = remindAt + self.userId = userId + } + + enum CodingKeys: String, CodingKey { + case remindAt = "remind_at" + case userId = "user_id" + } +} + +/// A response containing a list of reminders +struct RemindersQueryPayload: Decodable { + let reminders: [ReminderPayload] + let next: String? + let prev: String? +} + +/// A response containing a single reminder +struct ReminderResponsePayload: Decodable { + let reminder: ReminderPayload +} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index 35ea330b58f..d01148bde23 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -189,4 +189,105 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) XCTAssertEqual("messages/\(messageId)/translate", endpoint.path.value) } + + // MARK: - Reminder Endpoints Tests + + func test_createReminder_buildsCorrectly() { + let messageId: MessageId = .unique + let remindAt = Date() + let request = ReminderRequestBody(remindAt: remindAt) + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + + let endpoint: Endpoint = .createReminder(messageId: messageId, request: request) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_updateReminder_buildsCorrectly() { + let messageId: MessageId = .unique + let remindAt = Date() + let request = ReminderRequestBody(remindAt: remindAt) + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .patch, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + + let endpoint: Endpoint = .updateReminder(messageId: messageId, request: request) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_deleteReminder_withoutUserId_buildsCorrectly() { + let messageId: MessageId = .unique + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: nil + ) + + let endpoint: Endpoint = .deleteReminder(messageId: messageId) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_deleteReminder_withUserId_buildsCorrectly() { + let messageId: MessageId = .unique + let userId: UserId = .unique + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: ["user_id": AnyEncodable(userId)] + ) + + let endpoint: Endpoint = .deleteReminder(messageId: messageId, userId: userId) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_queryReminders_buildsCorrectly() { + let query = MessageReminderListQuery( + filter: .equal(.userId, to: "test-user"), + sort: [.init(key: .remindAt, isAscending: true)], + pageSize: 25 + ) + + let expectedEndpoint = Endpoint( + path: .reminders, + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: query + ) + + let endpoint: Endpoint = .queryReminders(query: query) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("reminders/query", endpoint.path.value) + } } From ad6f17d8d96a29492457ebc2616d20cd21808b0c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Mar 2025 16:48:37 +0000 Subject: [PATCH 03/42] Add MessageReminderListQuery --- .../Query/MessageReminderListQuery.swift | 127 ++++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 10 ++ .../MessageReminderListQuery_Tests.swift | 126 +++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 Sources/StreamChat/Query/MessageReminderListQuery.swift create mode 100644 Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift diff --git a/Sources/StreamChat/Query/MessageReminderListQuery.swift b/Sources/StreamChat/Query/MessageReminderListQuery.swift new file mode 100644 index 00000000000..969b3eb071e --- /dev/null +++ b/Sources/StreamChat/Query/MessageReminderListQuery.swift @@ -0,0 +1,127 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A namespace for the `FilterKey`s suitable to be used for `MessageReminderListQuery`. This scope is not aware of any extra data types. +public protocol AnyMessageReminderListFilterScope {} + +/// An extra-data-specific namespace for the `FilterKey`s suitable to be used for `MessageReminderListQuery`. +public struct MessageReminderListFilterScope: FilterScope, AnyMessageReminderListFilterScope {} + +/// Filter keys for message reminder list. +public extension FilterKey where Scope: AnyMessageReminderListFilterScope { + /// A filter key for matching the `channel_cid` value. + /// Supported operators: `in`, `equal` + static var channelCid: FilterKey { .init(rawValue: "channel_cid", keyPathString: "channelCid", valueMapper: { $0.rawValue }) } + + /// A filter key for matching the `message_id` value. + /// Supported operators: `in`, `equal` + static var messageId: FilterKey { .init(rawValue: "message_id", keyPathString: "messageId") } + + /// A filter key for matching the `remind_at` value. + /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` + static var remindAt: FilterKey { .init(rawValue: "remind_at", keyPathString: "remindAt") } + + /// A filter key for matching the `created_at` value. + /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` + static var createdAt: FilterKey { .init(rawValue: "created_at", keyPathString: "createdAt") } + + /// A filter key for matching the `user_id` value. + /// Supported operators: `in`, `equal` + static var userId: FilterKey { .init(rawValue: "user_id", keyPathString: "userId") } +} + +/// The type describing a value that can be used for sorting when querying message reminders. +public struct MessageReminderListSortingKey: RawRepresentable, Hashable, SortingKey { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +/// The supported sorting keys for message reminders. +public extension MessageReminderListSortingKey { + /// Sorts reminders by `remind_at` field. + static let remindAt = Self(rawValue: "remind_at") + + /// Sorts reminders by `created_at` field. + static let createdAt = Self(rawValue: "created_at") + + /// Sorts reminders by `updated_at` field. + static let updatedAt = Self(rawValue: "updated_at") +} + +/// A query is used for querying specific message reminders from backend. +/// You can specify filter, sorting, and pagination options. +public struct MessageReminderListQuery: Encodable { + private enum CodingKeys: String, CodingKey { + case filter + case sort + case limit + case next + case prev + } + + /// A filter for the query (see `Filter`). + public let filter: Filter? + /// A sorting for the query (see `Sorting`). + public let sort: [Sorting] + /// A pagination. + public var pagination: Pagination + /// Next page token for pagination + public var next: String? + /// Previous page token for pagination + public var prev: String? + + /// Init a message reminders query. + /// - Parameters: + /// - filter: a reminders filter. + /// - sort: a sorting list for reminders. + /// - pageSize: a page size for pagination. + /// - next: a token for fetching the next page. + /// - prev: a token for fetching the previous page. + public init( + filter: Filter? = nil, + sort: [Sorting] = [.init(key: .remindAt, isAscending: true)], + pageSize: Int = 25, + next: String? = nil, + prev: String? = nil + ) { + self.filter = filter + self.sort = sort + pagination = Pagination(pageSize: pageSize) + self.next = next + self.prev = prev + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + if let filter = filter { + try container.encode(filter, forKey: .filter) + } + + if !sort.isEmpty { + try container.encode(sort, forKey: .sort) + } + + try container.encode(pagination.pageSize, forKey: .limit) + + if let next = next { + try container.encode(next, forKey: .next) + } + + if let prev = prev { + try container.encode(prev, forKey: .prev) + } + } +} + +extension MessageReminderListQuery: CustomDebugStringConvertible { + public var debugDescription: String { + "Filter: \(String(describing: filter)) | Sort: \(sort)" + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b2d9a14d7c5..7aea694f3c1 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1681,6 +1681,9 @@ ADAA377125E43C3700C31528 /* ChatSuggestionsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD5A9E725DE8AF6006DC88A /* ChatSuggestionsVC_Tests.swift */; }; ADAA9F412B2240300078C3D4 /* TextViewMentionedUsersHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADAA9F402B2240300078C3D4 /* TextViewMentionedUsersHandler_Tests.swift */; }; ADAC47AA275A7C960027B672 /* ChatMessageContentView_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADAC47A9275A7C960027B672 /* ChatMessageContentView_Documentation_Tests.swift */; }; + ADB2087F2D849184003F1059 /* MessageReminderListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB2087E2D849184003F1059 /* MessageReminderListQuery.swift */; }; + ADB208802D849184003F1059 /* MessageReminderListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB2087E2D849184003F1059 /* MessageReminderListQuery.swift */; }; + ADB208822D8494F0003F1059 /* MessageReminderListQuery_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB208812D8494F0003F1059 /* MessageReminderListQuery_Tests.swift */; }; ADB22F7C25F1626200853C92 /* OnlineIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB22F7A25F1626200853C92 /* OnlineIndicatorView.swift */; }; ADB22F7D25F1626200853C92 /* ChatPresenceAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB22F7B25F1626200853C92 /* ChatPresenceAvatarView.swift */; }; ADB4166C26208F1C00E623E3 /* AttachmentPreviewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB4165026208C7900E623E3 /* AttachmentPreviewProvider.swift */; }; @@ -4394,6 +4397,8 @@ ADAA10EA2B90D589007AB03F /* FakeTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTimer.swift; sourceTree = ""; }; ADAA9F402B2240300078C3D4 /* TextViewMentionedUsersHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewMentionedUsersHandler_Tests.swift; sourceTree = ""; }; ADAC47A9275A7C960027B672 /* ChatMessageContentView_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContentView_Documentation_Tests.swift; sourceTree = ""; }; + ADB2087E2D849184003F1059 /* MessageReminderListQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderListQuery.swift; sourceTree = ""; }; + ADB208812D8494F0003F1059 /* MessageReminderListQuery_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderListQuery_Tests.swift; sourceTree = ""; }; ADB22F7A25F1626200853C92 /* OnlineIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnlineIndicatorView.swift; sourceTree = ""; }; ADB22F7B25F1626200853C92 /* ChatPresenceAvatarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatPresenceAvatarView.swift; sourceTree = ""; }; ADB4165026208C7900E623E3 /* AttachmentPreviewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewProvider.swift; sourceTree = ""; }; @@ -5668,6 +5673,7 @@ AD6E32A02BBC50110073831B /* ThreadListQuery.swift */, AD6E32A32BBC502D0073831B /* ThreadQuery.swift */, AD545E622D528271008FD399 /* DraftListQuery.swift */, + ADB2087E2D849184003F1059 /* MessageReminderListQuery.swift */, AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */, 7978FBB926E15A58002CA2DF /* MessageSearchQuery.swift */, 792A4F442480107A00EAF71D /* Pagination.swift */, @@ -7509,6 +7515,7 @@ A364D0B727D12A520029857A /* Query */ = { isa = PBXGroup; children = ( + ADB208812D8494F0003F1059 /* MessageReminderListQuery_Tests.swift */, AD545E842D5D7591008FD399 /* DraftListQuery_Tests.swift */, A3C7BAD027E4E02700BBF4FA /* ChannelListFilterScope_Tests.swift */, 799F611A2530B62C007F218C /* ChannelListQuery_Tests.swift */, @@ -11462,6 +11469,7 @@ AD7BE16A2C209888000A5756 /* ThreadEvents.swift in Sources */, 88BDCA8A2642B02D0099AD74 /* ChatMessageAttachment.swift in Sources */, 841BAA4E2BD1CD76000C73E4 /* PollOptionDTO.swift in Sources */, + ADB208802D849184003F1059 /* MessageReminderListQuery.swift in Sources */, 88E26D6E2580F34B00F55AB5 /* AttachmentQueueUploader.swift in Sources */, 4F312D0E2C905A2E0073A1BC /* FlagRequestBody.swift in Sources */, 88A00DD02525F08000259AB4 /* ModerationEndpoints.swift in Sources */, @@ -11892,6 +11900,7 @@ 792FCB4724A33CC2000290C7 /* EventDataProcessorMiddleware_Tests.swift in Sources */, 797EEA4A24FFC37600C81203 /* ConnectionStatus_Tests.swift in Sources */, 79877A2B2498E51500015F8B /* UserDTO_Tests.swift in Sources */, + ADB208822D8494F0003F1059 /* MessageReminderListQuery_Tests.swift in Sources */, 799F611B2530B62C007F218C /* ChannelListQuery_Tests.swift in Sources */, 792921C524C0479700116BBB /* ChannelListUpdater_Tests.swift in Sources */, A3F65E3727EB7161003F6256 /* WebSocketClient_Tests.swift in Sources */, @@ -12295,6 +12304,7 @@ C121E822274544AD00023E4C /* WebSocketEngine.swift in Sources */, C1B0B38427BFC08900C8207D /* EndpointPath+OfflineRequest.swift in Sources */, C121E823274544AD00023E4C /* URLSessionWebSocketEngine.swift in Sources */, + ADB2087F2D849184003F1059 /* MessageReminderListQuery.swift in Sources */, 79D5CDD527EA1BE300BE7D8B /* MessageTranslationsPayload.swift in Sources */, C121E825274544AD00023E4C /* BackgroundTaskScheduler.swift in Sources */, C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */, diff --git a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift new file mode 100644 index 00000000000..cc6392deb4c --- /dev/null +++ b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift @@ -0,0 +1,126 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class MessageReminderListQuery_Tests: XCTestCase { + func test_defaultInitialization() { + let query = MessageReminderListQuery() + + XCTAssertNil(query.filter) + XCTAssertEqual(query.pagination.pageSize, 25) + XCTAssertEqual(query.pagination.offset, 0) + XCTAssertEqual(query.sort.count, 1) + XCTAssertEqual(query.sort[0].key, .remindAt) + XCTAssertTrue(query.sort[0].isAscending) + XCTAssertNil(query.next) + XCTAssertNil(query.prev) + } + + func test_customInitialization() { + let filter = Filter.equal(.userId, to: "user-id") + let sort = [Sorting(key: .createdAt, isAscending: false)] + let next = "next-token" + let prev = "prev-token" + + let query = MessageReminderListQuery( + filter: filter, + sort: sort, + pageSize: 10, + next: next, + prev: prev + ) + + XCTAssertEqual(query.filter?.filterHash, filter.filterHash) + XCTAssertEqual(query.pagination.pageSize, 10) + XCTAssertEqual(query.sort.count, 1) + XCTAssertEqual(query.sort[0].key, .createdAt) + XCTAssertFalse(query.sort[0].isAscending) + XCTAssertEqual(query.next, next) + XCTAssertEqual(query.prev, prev) + } + + func test_encode_withAllFields() throws { + let filter = Filter.equal(.userId, to: "user-id") + let sort = [Sorting(key: .createdAt, isAscending: false)] + let next = "next-token" + let prev = "prev-token" + + let query = MessageReminderListQuery( + filter: filter, + sort: sort, + pageSize: 10, + next: next, + prev: prev + ) + + let expectedData: [String: Any] = [ + "filter": ["user_id": ["$eq": "user-id"]], + "sort": [["field": "created_at", "direction": -1]], + "limit": 10, + "next": next, + "prev": prev + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + AssertJSONEqual(expectedJSON, encodedJSON) + } + + func test_encode_withoutFilter() throws { + let sort = [Sorting(key: .createdAt, isAscending: false)] + + let query = MessageReminderListQuery( + filter: nil, + sort: sort, + pageSize: 10 + ) + + let expectedData: [String: Any] = [ + "sort": [["field": "created_at", "direction": -1]], + "limit": 10 + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + AssertJSONEqual(expectedJSON, encodedJSON) + } + + func test_encode_withoutSort() throws { + let filter = Filter.equal(.userId, to: "user-id") + + let query = MessageReminderListQuery( + filter: filter, + sort: [], + pageSize: 10 + ) + + let expectedData: [String: Any] = [ + "filter": ["user_id": ["$eq": "user-id"]], + "limit": 10 + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + AssertJSONEqual(expectedJSON, encodedJSON) + } + + func test_filterKeys() { + // Test the filter keys for proper values + XCTAssertEqual(FilterKey.channelCid.rawValue, "channel_cid") + XCTAssertEqual(FilterKey.messageId.rawValue, "message_id") + XCTAssertEqual(FilterKey.remindAt.rawValue, "remind_at") + XCTAssertEqual(FilterKey.createdAt.rawValue, "created_at") + XCTAssertEqual(FilterKey.userId.rawValue, "user_id") + } + + func test_sortingKeys() { + // Test the sorting keys for proper values + XCTAssertEqual(MessageReminderListSortingKey.remindAt.rawValue, "remind_at") + XCTAssertEqual(MessageReminderListSortingKey.createdAt.rawValue, "created_at") + XCTAssertEqual(MessageReminderListSortingKey.updatedAt.rawValue, "updated_at") + } +} From 1608d893588dd618225759e0554b2ec733bc2cb7 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Mar 2025 17:27:17 +0000 Subject: [PATCH 04/42] Change the default baseURL of Frankfurt C2 to staging env --- .../AppConfigViewController.swift | 3 +++ DemoApp/Shared/DemoUsers.swift | 13 +++++++++++-- DemoApp/Shared/StreamChatWrapper.swift | 5 +++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 5bb0d19a69f..1081afb0613 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -735,6 +735,9 @@ class AppConfigViewController: UITableViewController { guard let selectedOption = options.first else { return } apiKeyString = selectedOption.rawValue StreamChatWrapper.replaceSharedInstance(apiKeyString: apiKeyString) + if let baseURL = selectedOption.customBaseURL { + self?.chatClientConfig.baseURL = .init(url: baseURL) + } self?.tableView.reloadData() } diff --git a/DemoApp/Shared/DemoUsers.swift b/DemoApp/Shared/DemoUsers.swift index a637e47b609..22b380010e4 100644 --- a/DemoApp/Shared/DemoUsers.swift +++ b/DemoApp/Shared/DemoUsers.swift @@ -257,18 +257,27 @@ struct DemoApiKeys: RawRepresentable, Equatable, Hashable { } static let frankfurtC1: DemoApiKeys = .init(rawValue: "8br4watad788") // UIKit default - static let frankfurtC2: DemoApiKeys = .init(rawValue: "pd67s34fzpgw") + static let frankfurtC2: DemoApiKeys = .init(rawValue: "pd67s34fzpgw") // Frankfurt C2 Staging static let usEastC6: DemoApiKeys = .init(rawValue: "zcgvnykxsfm8") // SwiftUI default var appName: String? { switch self { case .frankfurtC1: return "UIKit" - case .frankfurtC2: return nil + case .frankfurtC2: return "Frankfurt C2 Staging" case .usEastC6: return "SwiftUI" default: return nil } } + var customBaseURL: URL? { + switch self { + case .frankfurtC1: return nil + case .frankfurtC2: return URL(string: "https://chat-edge-frankfurt-ce1.stream-io-api.com/") + case .usEastC6: return nil + default: return nil + } + } + static func ~= (pattern: DemoApiKeys, value: DemoApiKeys) -> Bool { value.rawValue == pattern.rawValue } diff --git a/DemoApp/Shared/StreamChatWrapper.swift b/DemoApp/Shared/StreamChatWrapper.swift index 48b0f052ed2..a98412f6088 100644 --- a/DemoApp/Shared/StreamChatWrapper.swift +++ b/DemoApp/Shared/StreamChatWrapper.swift @@ -37,6 +37,11 @@ final class StreamChatWrapper { config.shouldShowShadowedMessages = true config.applicationGroupIdentifier = applicationGroupIdentifier config.urlSessionConfiguration.httpAdditionalHeaders = ["Custom": "Example"] + + let apiKey = DemoApiKeys(rawValue: apiKeyString) + if let baseURL = apiKey.customBaseURL { + config.baseURL = .init(url: baseURL) + } // Uncomment this to test model transformers // config.modelsTransformer = CustomStreamModelsTransformer() configureUI() From 29c2b1b9b244a5c7ebfd971815166375b2a25491 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 14 Mar 2025 17:47:51 +0000 Subject: [PATCH 05/42] Remove user_id from request payloads since this is server side only --- .../Endpoints/MessageEndpoints.swift | 11 +++------ .../Endpoints/Payloads/MessagePayloads.swift | 6 +---- .../Query/MessageReminderListQuery.swift | 4 ---- .../Endpoints/MessageEndpoints_Tests.swift | 23 ++----------------- .../MessageReminderListQuery_Tests.swift | 11 ++++----- 5 files changed, 11 insertions(+), 44 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index 0d784d93ed5..06c356c8d3f 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -120,18 +120,13 @@ extension Endpoint { } // Deletes a reminder for a message - static func deleteReminder(messageId: MessageId, userId: UserId? = nil) -> Endpoint { - var body: [String: AnyEncodable]? - if let userId = userId { - body = ["user_id": AnyEncodable(userId)] - } - - return .init( + static func deleteReminder(messageId: MessageId) -> Endpoint { + .init( path: .reminder(messageId), method: .delete, queryItems: nil, requiresConnectionId: false, - body: body + body: nil ) } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 48e6506e631..ff2baf034a4 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -433,19 +433,15 @@ struct ReminderPayload: Decodable { /// A request body for creating or updating a reminder struct ReminderRequestBody: Encodable { let remindAt: Date? - let userId: UserId? init( - remindAt: Date?, - userId: UserId? = nil + remindAt: Date? ) { self.remindAt = remindAt - self.userId = userId } enum CodingKeys: String, CodingKey { case remindAt = "remind_at" - case userId = "user_id" } } diff --git a/Sources/StreamChat/Query/MessageReminderListQuery.swift b/Sources/StreamChat/Query/MessageReminderListQuery.swift index 969b3eb071e..4fc5392d810 100644 --- a/Sources/StreamChat/Query/MessageReminderListQuery.swift +++ b/Sources/StreamChat/Query/MessageReminderListQuery.swift @@ -27,10 +27,6 @@ public extension FilterKey where Scope: AnyMessageReminderListFilterScope { /// A filter key for matching the `created_at` value. /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` static var createdAt: FilterKey { .init(rawValue: "created_at", keyPathString: "createdAt") } - - /// A filter key for matching the `user_id` value. - /// Supported operators: `in`, `equal` - static var userId: FilterKey { .init(rawValue: "user_id", keyPathString: "userId") } } /// The type describing a value that can be used for sorting when querying message reminders. diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index d01148bde23..dbc1fcded25 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -232,7 +232,7 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) } - func test_deleteReminder_withoutUserId_buildsCorrectly() { + func test_deleteReminder_buildsCorrectly() { let messageId: MessageId = .unique let expectedEndpoint = Endpoint( @@ -250,28 +250,9 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) } - func test_deleteReminder_withUserId_buildsCorrectly() { - let messageId: MessageId = .unique - let userId: UserId = .unique - - let expectedEndpoint = Endpoint( - path: .reminder(messageId), - method: .delete, - queryItems: nil, - requiresConnectionId: false, - body: ["user_id": AnyEncodable(userId)] - ) - - let endpoint: Endpoint = .deleteReminder(messageId: messageId, userId: userId) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) - } - func test_queryReminders_buildsCorrectly() { let query = MessageReminderListQuery( - filter: .equal(.userId, to: "test-user"), + filter: .equal(.channelCid, to: ChannelId.unique), sort: [.init(key: .remindAt, isAscending: true)], pageSize: 25 ) diff --git a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift index cc6392deb4c..c51f6947399 100644 --- a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift +++ b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift @@ -21,7 +21,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { } func test_customInitialization() { - let filter = Filter.equal(.userId, to: "user-id") + let filter = Filter.equal(.channelCid, to: ChannelId.unique) let sort = [Sorting(key: .createdAt, isAscending: false)] let next = "next-token" let prev = "prev-token" @@ -44,7 +44,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { } func test_encode_withAllFields() throws { - let filter = Filter.equal(.userId, to: "user-id") + let filter = Filter.equal(.channelCid, to: ChannelId.unique) let sort = [Sorting(key: .createdAt, isAscending: false)] let next = "next-token" let prev = "prev-token" @@ -58,7 +58,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { ) let expectedData: [String: Any] = [ - "filter": ["user_id": ["$eq": "user-id"]], + "filter": ["channel_cid": ["$eq": filter.value]], "sort": [["field": "created_at", "direction": -1]], "limit": 10, "next": next, @@ -90,7 +90,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { } func test_encode_withoutSort() throws { - let filter = Filter.equal(.userId, to: "user-id") + let filter = Filter.equal(.channelCid, to: ChannelId.unique) let query = MessageReminderListQuery( filter: filter, @@ -99,7 +99,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { ) let expectedData: [String: Any] = [ - "filter": ["user_id": ["$eq": "user-id"]], + "filter": ["channel_cid": ["$eq": filter.value]], "limit": 10 ] @@ -114,7 +114,6 @@ final class MessageReminderListQuery_Tests: XCTestCase { XCTAssertEqual(FilterKey.messageId.rawValue, "message_id") XCTAssertEqual(FilterKey.remindAt.rawValue, "remind_at") XCTAssertEqual(FilterKey.createdAt.rawValue, "created_at") - XCTAssertEqual(FilterKey.userId.rawValue, "user_id") } func test_sortingKeys() { From b130b124a982a3be65b99d9442ce4303dbb5c6c8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 17 Mar 2025 17:37:30 +0000 Subject: [PATCH 06/42] Add MessageReminder and MessageReminderDTO --- .../Endpoints/Payloads/MessagePayloads.swift | 8 -- .../Database/DTOs/MessageReminderDTO.swift | 134 ++++++++++++++++++ .../StreamChat/Database/DatabaseSession.swift | 18 +++ .../StreamChatModel.xcdatamodel/contents | 15 +- .../StreamChat/Models/MessageReminder.swift | 53 +++++++ StreamChat.xcodeproj/project.pbxproj | 12 ++ 6 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift create mode 100644 Sources/StreamChat/Models/MessageReminder.swift diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index ff2baf034a4..9cc8bf5e70c 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -389,8 +389,6 @@ public struct Command: Codable, Hashable { /// An object describing a reminder JSON payload. struct ReminderPayload: Decodable { - let userId: UserId - let user: UserPayload? let channelCid: ChannelId let messageId: MessageId let message: MessagePayload? @@ -399,8 +397,6 @@ struct ReminderPayload: Decodable { let updatedAt: Date init( - userId: UserId, - user: UserPayload? = nil, channelCid: ChannelId, messageId: MessageId, message: MessagePayload? = nil, @@ -408,8 +404,6 @@ struct ReminderPayload: Decodable { createdAt: Date, updatedAt: Date ) { - self.userId = userId - self.user = user self.channelCid = channelCid self.messageId = messageId self.message = message @@ -419,8 +413,6 @@ struct ReminderPayload: Decodable { } enum CodingKeys: String, CodingKey { - case userId = "user_id" - case user case channelCid = "channel_cid" case messageId = "message_id" case message diff --git a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift new file mode 100644 index 00000000000..ef991c0fe15 --- /dev/null +++ b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift @@ -0,0 +1,134 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation + +@objc(MessageReminderDTO) +class MessageReminderDTO: NSManagedObject { + @NSManaged var id: String + @NSManaged var remindAt: DBDate? + @NSManaged var createdAt: DBDate + @NSManaged var updatedAt: DBDate + + // Relationships + @NSManaged var message: MessageDTO + @NSManaged var channel: ChannelDTO + + /// Returns a fetch request for a message reminder with the provided message ID. + static func fetchRequest(messageId: MessageId) -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageReminderDTO.entityName) + request.predicate = NSPredicate(format: "message.id == %@", messageId) + request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageReminderDTO.createdAt, ascending: false)] + return request + } + + /// Returns a fetch request for message reminders belonging to the provided channel. + static func fetchRequest(cid: ChannelId) -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageReminderDTO.entityName) + request.predicate = NSPredicate(format: "channel.cid == %@", cid.rawValue) + request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageReminderDTO.remindAt, ascending: true)] + return request + } + + /// Loads a reminder with the specified message ID from the context. + static func load(messageId: MessageId, context: NSManagedObjectContext) -> MessageReminderDTO? { + let request = fetchRequest(messageId: messageId) + return load(by: request, context: context).first + } + + /// Loads or creates a reminder with the specified message ID. + static func loadOrCreate( + messageId: MessageId, + context: NSManagedObjectContext, + cache: PreWarmedCache? + ) -> MessageReminderDTO { + // Try to reuse existing object if available + if let existing = load(messageId: messageId, context: context) { + return existing + } + + let request = fetchRequest(messageId: messageId) + let new = NSEntityDescription.insertNewObject(into: context, for: request) + return new + } + + /// Loads a message reminder DTO with the specified id. + /// - Parameters: + /// - id: The reminder id to look for. + /// - context: NSManagedObjectContext to fetch from. + /// - Returns: The message reminder DTO with the specified id, if exists. + static func load(id: String, context: NSManagedObjectContext) -> MessageReminderDTO? { + let request = NSFetchRequest(entityName: MessageReminderDTO.entityName) + request.predicate = NSPredicate(format: "id == %@", id) + return try? context.fetch(request).first + } +} + +extension NSManagedObjectContext: ReminderDatabaseSession { + /// Creates or updates a reminder for a message. + func saveReminder( + payload: ReminderPayload, + cache: PreWarmedCache? + ) throws -> MessageReminderDTO { + let messageDTO: MessageDTO + if let existingMessage = MessageDTO.load(id: payload.messageId, context: self) { + messageDTO = existingMessage + } else if let messagePayload = payload.message { + messageDTO = try saveMessage(payload: messagePayload, for: payload.channelCid, cache: cache) + } else { + throw ClientError.MessageDoesNotExist(messageId: payload.messageId) + } + + let channelDTO: ChannelDTO + if let existingChannel = ChannelDTO.load(cid: payload.channelCid, context: self) { + channelDTO = existingChannel + } else if let channelPayload = payload.message?.channel { + channelDTO = try saveChannel(payload: channelPayload, query: nil, cache: nil) + } else { + throw ClientError.ChannelDoesNotExist(cid: payload.channelCid) + } + + let reminderDTO = MessageReminderDTO.loadOrCreate( + messageId: payload.messageId, + context: self, + cache: cache + ) + + reminderDTO.remindAt = payload.remindAt?.bridgeDate + reminderDTO.createdAt = payload.createdAt.bridgeDate + reminderDTO.updatedAt = payload.updatedAt.bridgeDate + reminderDTO.message = messageDTO + reminderDTO.channel = channelDTO + + return reminderDTO + } + + /// Deletes a reminder for the specified message ID. + func deleteReminder(messageId: MessageId) { + guard let reminderDTO = MessageReminderDTO.load(messageId: messageId, context: self) else { + return + } + + delete(reminderDTO) + } +} + +// MARK: - Converting to domain model + +extension MessageReminderDTO { + /// Snapshots the current state of `MessageReminderDTO` and returns an immutable model object from it. + /// - Returns: A `MessageReminder` instance created from the DTO data. + /// - Throws: An error when the underlying data is inconsistent. + func asModel() throws -> MessageReminder { + MessageReminder( + id: id, + remindAt: remindAt?.bridgeDate, + message: try message.asModel(), + channel: try channel.asModel(), + createdAt: createdAt.bridgeDate, + updatedAt: updatedAt.bridgeDate + ) + } +} diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index 78206e75f91..1f3cb579e60 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -542,6 +542,23 @@ protocol ThreadReadDatabaseSession { ) } +protocol ReminderDatabaseSession { + /// Saves a reminder with the provided payload. + /// - Parameters: + /// - payload: The `ReminderPayload` containing the details of the reminder to be saved. + /// - cache: An optional `PreWarmedCache` to optimize the save operation. + /// - Returns: A `MessageReminderDTO` representing the saved reminder. + /// - Throws: An error if the save operation fails. + @discardableResult + func saveReminder( + payload: ReminderPayload, + cache: PreWarmedCache? + ) throws -> MessageReminderDTO + + /// Deletes a reminder for the specified message ID. + func deleteReminder(messageId: MessageId) +} + protocol PollDatabaseSession { /// Saves a poll with the provided payload. /// - Parameters: @@ -663,6 +680,7 @@ protocol DatabaseSession: UserDatabaseSession, QueuedRequestDatabaseSession, ThreadDatabaseSession, ThreadReadDatabaseSession, + ReminderDatabaseSession, PollDatabaseSession {} extension DatabaseSession { diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 1a43f6d4654..1773f022e8a 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -73,6 +73,7 @@ + @@ -259,6 +260,7 @@ + @@ -317,6 +319,17 @@ + + + + + + + + + + + diff --git a/Sources/StreamChat/Models/MessageReminder.swift b/Sources/StreamChat/Models/MessageReminder.swift new file mode 100644 index 00000000000..12d1c6e4f08 --- /dev/null +++ b/Sources/StreamChat/Models/MessageReminder.swift @@ -0,0 +1,53 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A type representing a message reminder. +public struct MessageReminder { + /// A unique identifier of the reminder, based on the message ID. + public let id: String + + /// The date when the user should be reminded about this message. + /// If nil, this is a bookmark type reminder without a notification. + public let remindAt: Date? + + /// The message that has been marked for reminder. + public let message: ChatMessage + + /// The channel where the message belongs to. + public let channel: ChatChannel + + /// Date when the reminder was created on the server. + public let createdAt: Date + + /// A date when the reminder was updated last time. + public let updatedAt: Date + + init( + id: String, + remindAt: Date?, + message: ChatMessage, + channel: ChatChannel, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.remindAt = remindAt + self.message = message + self.channel = channel + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension MessageReminder: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 7aea694f3c1..1531edc2e4c 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1687,6 +1687,10 @@ ADB22F7C25F1626200853C92 /* OnlineIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB22F7A25F1626200853C92 /* OnlineIndicatorView.swift */; }; ADB22F7D25F1626200853C92 /* ChatPresenceAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB22F7B25F1626200853C92 /* ChatPresenceAvatarView.swift */; }; ADB4166C26208F1C00E623E3 /* AttachmentPreviewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB4165026208C7900E623E3 /* AttachmentPreviewProvider.swift */; }; + ADB8B8EA2D8890B900549C95 /* MessageReminderDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */; }; + ADB8B8EB2D8890B900549C95 /* MessageReminderDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */; }; + ADB8B8ED2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; + ADB8B8EE2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; ADB951A1291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951AB291C1DE400800554 /* AttachmentUploader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */; }; @@ -4402,6 +4406,8 @@ ADB22F7A25F1626200853C92 /* OnlineIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnlineIndicatorView.swift; sourceTree = ""; }; ADB22F7B25F1626200853C92 /* ChatPresenceAvatarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatPresenceAvatarView.swift; sourceTree = ""; }; ADB4165026208C7900E623E3 /* AttachmentPreviewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewProvider.swift; sourceTree = ""; }; + ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderDTO.swift; sourceTree = ""; }; + ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder.swift; sourceTree = ""; }; ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader_Spy.swift; sourceTree = ""; }; ADB951AC291C22DB00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; @@ -5584,6 +5590,7 @@ 792A4F18247EA97000EAF71D /* DTOs */ = { isa = PBXGroup; children = ( + ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */, DABC6AC7254707CB00A8FC78 /* AttachmentDTO.swift */, AD52A2182804850700D0157E /* ChannelConfigDTO.swift */, 799C942A247D2FB9001F1104 /* ChannelDTO.swift */, @@ -5937,6 +5944,7 @@ 79877A022498E4BB00015F8B /* Device.swift */, 79877A032498E4BB00015F8B /* Member.swift */, AD70DC3B2ADEF09C00CFC3B7 /* MessageModerationDetails.swift */, + ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */, AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */, 8899BC52254318CC003CB98B /* MessageReaction.swift */, AD8258A22BD2939500B9ED74 /* MessageReactionGroup.swift */, @@ -11637,6 +11645,7 @@ 792A4F482480107A00EAF71D /* Pagination.swift in Sources */, 79AF43B42632AF1C00E75CDA /* ChannelVisibilityEventMiddleware.swift in Sources */, DAF1BED525066114003CEDC0 /* MessageController+Combine.swift in Sources */, + ADB8B8EA2D8890B900549C95 /* MessageReminderDTO.swift in Sources */, A36C39F52860680A0004EB7E /* URL+EnrichedURL.swift in Sources */, 8A0C3BBC24C0947400CAFD19 /* UserEvents.swift in Sources */, DA4EE5B2252B67F500CB26D4 /* UserListController+SwiftUI.swift in Sources */, @@ -11649,6 +11658,7 @@ A30C3F20276B428F00DA5968 /* UnknownUserEvent.swift in Sources */, 40789D2129F6AC500018C2BB /* AppStateObserving.swift in Sources */, 4F427F6C2BA2F53200D92238 /* ConnectedUserState+Observer.swift in Sources */, + ADB8B8EE2D8890E000549C95 /* MessageReminder.swift in Sources */, 4F83FA462BA43DC3008BD8CD /* MemberList.swift in Sources */, 7963BD6926B0208900281F8C /* ChatMessageAudioAttachment.swift in Sources */, 697C6F90260CFA37000E9023 /* Deprecations.swift in Sources */, @@ -12403,6 +12413,7 @@ C121E85F274544AE00023E4C /* UserUpdater.swift in Sources */, AD0CC02C2BDC01A2005E2C66 /* ReactionListController.swift in Sources */, C121E860274544AE00023E4C /* ChannelMemberListUpdater.swift in Sources */, + ADB8B8ED2D8890E000549C95 /* MessageReminder.swift in Sources */, 4042969029FBCE1D0089126D /* AudioSamplesExtractor_Tests.swift in Sources */, C121E861274544AE00023E4C /* ChannelMemberUpdater.swift in Sources */, 4F427F6D2BA2F53200D92238 /* ConnectedUserState+Observer.swift in Sources */, @@ -12540,6 +12551,7 @@ C121E8A7274544B000023E4C /* MemberListController.swift in Sources */, C121E8A8274544B000023E4C /* MemberListController+SwiftUI.swift in Sources */, 8413D2ED2BDC63FA005ADA4E /* PollVoteListQuery.swift in Sources */, + ADB8B8EB2D8890B900549C95 /* MessageReminderDTO.swift in Sources */, C121E8A9274544B000023E4C /* MemberListController+Combine.swift in Sources */, C121E8AA274544B000023E4C /* ChatChannelWatcherListController.swift in Sources */, C143788E27BBEBB900E23965 /* OfflineRequestsRepository.swift in Sources */, From 51b49764ce440f1f3217bb9444979bd55f27bab9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 17 Mar 2025 18:32:44 +0000 Subject: [PATCH 07/42] Add createReminder, updateReminder and deleteReminder actions --- .../MessageController/MessageController.swift | 58 ++++++ .../StreamChat/Database/DTOs/MessageDTO.swift | 2 + .../Database/DTOs/MessageReminderDTO.swift | 3 +- .../StreamChat/Workers/MessageUpdater.swift | 193 ++++++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index de88b4c0f67..b6363551f9b 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -930,6 +930,64 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP } } } + + // MARK: - Reminder Actions + + /// Creates a new reminder for this message. + /// - Parameters: + /// - remindAt: The date when the user should be reminded about this message. + /// If nil, this creates a "save for later" type reminder without a notification. + /// - completion: Called when the API call is finished with the result of the operation. + public func createReminder( + remindAt: Date?, + completion: ((Result) -> Void)? = nil + ) { + messageUpdater.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { result in + self.callback { + completion?(result) + } + } + } + + /// Updates the reminder for this message. + /// - Parameters: + /// - remindAt: The new date when the user should be reminded about this message. + /// If nil, this updates to a "save for later" type reminder without a notification. + /// - completion: Called when the API call is finished with the result of the operation. + public func updateReminder( + remindAt: Date?, + completion: ((Result) -> Void)? = nil + ) { + messageUpdater.updateReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { result in + self.callback { + completion?(result) + } + } + } + + /// Deletes the reminder for this message. + /// - Parameter completion: Called when the API call is finished. + /// If request fails, the completion will be called with an error. + public func deleteReminder( + completion: ((Error?) -> Void)? = nil + ) { + messageUpdater.deleteReminder( + messageId: messageId, + cid: cid + ) { error in + self.callback { + completion?(error) + } + } + } } // MARK: - Environment diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 83c8ff2bfe5..1e4997f1e92 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -88,6 +88,8 @@ class MessageDTO: NSManagedObject { @NSManaged var draftReply: MessageDTO? @NSManaged var isDraft: Bool + @NSManaged var reminder: MessageReminderDTO? + /// If the message is sent by the current user, this field /// contains channel reads of other channel members (excluding the current user), /// where `read.lastRead >= self.createdAt`. diff --git a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift index ef991c0fe15..75cb75acfe3 100644 --- a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift @@ -95,7 +95,8 @@ extension NSManagedObjectContext: ReminderDatabaseSession { context: self, cache: cache ) - + + reminderDTO.id = payload.messageId reminderDTO.remindAt = payload.remindAt?.bridgeDate reminderDTO.createdAt = payload.createdAt.bridgeDate reminderDTO.updatedAt = payload.updatedAt.bridgeDate diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index cc38c233cd0..e8992d65609 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -909,6 +909,199 @@ class MessageUpdater: Worker { } } } + + // MARK: - Reminder Actions + + /// Creates a new reminder for a message. + /// - Parameters: + /// - messageId: The message identifier to create a reminder for. + /// - cid: The channel identifier the message belongs to. + /// - remindAt: The date when the user should be reminded about this message. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + func createReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + let requestBody = ReminderRequestBody(remindAt: remindAt) + let endpoint: Endpoint = .createReminder( + messageId: messageId, + request: requestBody + ) + + // First optimistically create the reminder locally + database.write { session in + let now = Date() + let reminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + message: nil, + remindAt: remindAt, + createdAt: now, + updatedAt: now + ) + + do { + try session.saveReminder(payload: reminderPayload, cache: nil) + } catch { + log.warning("Failed to optimistically create reminder in the database: \(error)") + } + } completion: { [weak self] _ in + // Make the API call to create the reminder + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case .success(let payload): + var reminder: MessageReminder! + self?.database.write({ session in + let messageReminder = payload.reminder + reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() + }, completion: { error in + if let error { + completion(.failure(error)) + } else { + completion(.success(reminder)) + } + }) + case .failure(let error): + // Rollback the optimistic update if the API call fails + self?.database.write({ session in + session.deleteReminder(messageId: messageId) + }, completion: { _ in + completion(.failure(error)) + }) + } + } + } + } + + /// Updates an existing reminder for a message. + /// - Parameters: + /// - messageId: The message identifier for the reminder to update. + /// - cid: The channel identifier the message belongs to. + /// - remindAt: The new date when the user should be reminded about this message. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + func updateReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + let requestBody = ReminderRequestBody(remindAt: remindAt) + let endpoint: Endpoint = .updateReminder(messageId: messageId, request: requestBody) + + // Save current data for potential rollback + var originalRemindAt: Date? + + // First optimistically update the reminder locally + database.write { session in + // Verify the message exists + guard let messageDTO = session.message(id: messageId) else { + log.warning("Failed to find message with id: \(messageId) for updating reminder") + return + } + + originalRemindAt = messageDTO.reminder?.remindAt?.bridgeDate + + messageDTO.reminder?.remindAt = remindAt?.bridgeDate + } completion: { [weak self] _ in + // Make the API call to update the reminder + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case .success(let payload): + var reminder: MessageReminder! + self?.database.write({ session in + let messageReminder = payload.reminder + reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() + }, completion: { error in + if let error { + completion(.failure(error)) + } else { + completion(.success(reminder)) + } + }) + + case .failure(let error): + self?.database.write({ session in + // Restore original value + guard let messageDTO = session.message(id: messageId) else { + return + } + messageDTO.reminder?.remindAt = originalRemindAt?.bridgeDate + }, completion: { _ in + completion(.failure(error)) + }) + } + } + } + } + + /// Deletes a reminder for a message. + /// - Parameters: + /// - messageId: The message identifier for the reminder to delete. + /// - cid: The channel identifier the message belongs to. + /// - completion: Called when the API call is finished. Called with an error if the remote update fails. + func deleteReminder( + messageId: MessageId, + cid: ChannelId, + completion: ((Error?) -> Void)? = nil + ) { + let endpoint: Endpoint = .deleteReminder(messageId: messageId) + + // Save data for potential rollback + var originalPayload: ReminderPayload? + + // First optimistically delete the reminder locally + database.write { session in + // Verify the message exists + guard let messageDTO = session.message(id: messageId) else { + log.warning("Failed to find message with id: \(messageId) for deleting reminder") + return + } + + // Get original reminder data for potential rollback + if let reminderDTO = messageDTO.reminder { + // Store the original state for potential rollback + originalPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + message: nil, + remindAt: reminderDTO.remindAt?.bridgeDate, + createdAt: reminderDTO.createdAt.bridgeDate, + updatedAt: reminderDTO.updatedAt.bridgeDate + ) + } + + // Delete optimistically + session.deleteReminder(messageId: messageId) + } completion: { [weak self] _ in + // Make the API call to delete the reminder + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case .success: + completion?(nil) + + case .failure(let error): + // Rollback the optimistic delete if the API call fails + guard let originalPayload = originalPayload else { + completion?(error) + return + } + + self?.database.write({ session in + // Restore original reminder + do { + try session.saveReminder(payload: originalPayload, cache: nil) + } catch { + log.warning("Failed to rollback reminder deletion: \(error)") + } + }, completion: { _ in + completion?(error) + }) + } + } + } + } } extension MessageUpdater { From abfb7640d4a083e719744efcfca66a484760f4db Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Mar 2025 17:13:39 +0000 Subject: [PATCH 08/42] Add `Message.reminder` and update it or delete it when needed --- .../MessageController/MessageController.swift | 2 +- .../StreamChat/Database/DTOs/MessageDTO.swift | 14 +++++++++++- Sources/StreamChat/Models/ChatMessage.swift | 10 +++++++-- Sources/StreamChat/Models/DraftMessage.swift | 1 + .../StreamChat/Models/MessageReminder.swift | 22 +++++++++++++++++++ .../Events/MessageEvents.swift | 3 ++- 6 files changed, 47 insertions(+), 5 deletions(-) diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index b6363551f9b..bc6d3423e97 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -939,7 +939,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// If nil, this creates a "save for later" type reminder without a notification. /// - completion: Called when the API call is finished with the result of the operation. public func createReminder( - remindAt: Date?, + remindAt: Date? = nil, completion: ((Result) -> Void)? = nil ) { messageUpdater.createReminder( diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 1e4997f1e92..3ba00a62c73 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -1042,6 +1042,13 @@ extension NSManagedObjectContext: MessageDatabaseSession { dto.updateReadBy(withChannelReads: channelDTO.reads) } + if let reminder = payload.reminder { + dto.reminder = try saveReminder(payload: reminder, cache: cache) + } else if let reminderDTO = dto.reminder { + delete(reminderDTO) + dto.reminder = nil + } + // Refetch channel preview if the current preview has changed. // // The current message can stop being a valid preview e.g. @@ -1788,7 +1795,12 @@ private extension ChatMessage { readBy: readBy, poll: poll, textUpdatedAt: textUpdatedAt, - draftReply: draftReply.map(DraftMessage.init) + draftReply: draftReply.map(DraftMessage.init), + reminder: dto.reminder.map { .init( + remindAt: $0.remindAt?.bridgeDate, + createdAt: $0.createdAt.bridgeDate, + updatedAt: $0.updatedAt.bridgeDate + ) } ) if let transformer = chatClientConfig?.modelsTransformer { diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index f75be99d7f1..8081092faf4 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -67,6 +67,9 @@ public struct ChatMessage { /// The draft reply to this message. Applies only for the messages of the current user. public let draftReply: DraftMessage? + /// The reminder information for this message if it has been added to reminders. + public let reminder: MessageReminderInfo? + /// A flag indicating whether the message was bounced due to moderation. public let isBounced: Bool @@ -217,7 +220,8 @@ public struct ChatMessage { readBy: Set, poll: Poll?, textUpdatedAt: Date?, - draftReply: DraftMessage? + draftReply: DraftMessage?, + reminder: MessageReminderInfo? ) { self.id = id self.cid = cid @@ -259,6 +263,7 @@ public struct ChatMessage { _attachments = attachments _quotedMessage = { quotedMessage } self.draftReply = draftReply + self.reminder = reminder } /// Returns a new `ChatMessage` with the provided data replaced. @@ -306,7 +311,8 @@ public struct ChatMessage { readBy: readBy, poll: poll, textUpdatedAt: textUpdatedAt, - draftReply: draftReply + draftReply: draftReply, + reminder: reminder ) } } diff --git a/Sources/StreamChat/Models/DraftMessage.swift b/Sources/StreamChat/Models/DraftMessage.swift index 8742794a357..fb909bbf8ea 100644 --- a/Sources/StreamChat/Models/DraftMessage.swift +++ b/Sources/StreamChat/Models/DraftMessage.swift @@ -162,5 +162,6 @@ extension ChatMessage { poll = nil textUpdatedAt = nil draftReply = nil + reminder = nil } } diff --git a/Sources/StreamChat/Models/MessageReminder.swift b/Sources/StreamChat/Models/MessageReminder.swift index 12d1c6e4f08..aa13467c258 100644 --- a/Sources/StreamChat/Models/MessageReminder.swift +++ b/Sources/StreamChat/Models/MessageReminder.swift @@ -51,3 +51,25 @@ extension MessageReminder: Hashable { hasher.combine(id) } } + +/// A type representing the reminder information. +/// +/// Does not contain any reference to the message or channel so that +/// it can be used in these models without creating a circular reference. +public struct MessageReminderInfo { + /// The date when the user should be reminded about this message. + /// If nil, this is a bookmark type reminder without a notification. + public let remindAt: Date? + + /// Date when the reminder was created on the server. + public let createdAt: Date + + /// A date when the reminder was updated last time. + public let updatedAt: Date + + init(remindAt: Date?, createdAt: Date, updatedAt: Date) { + self.remindAt = remindAt + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift index bb85df84b8d..1580ac08d07 100644 --- a/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/MessageEvents.swift @@ -294,7 +294,8 @@ private extension MessagePayload { readBy: [], poll: nil, textUpdatedAt: messageTextUpdatedAt, - draftReply: nil + draftReply: nil, + reminder: nil ) } } From 2eff1f7be0639988be5145bdc6aa2d8908877342 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Mar 2025 17:14:39 +0000 Subject: [PATCH 09/42] Add demo app example to create, update and delete reminders --- .../Components/DemoChatMessageActionsVC.swift | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index 6336fa12d6d..fc3610522ce 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift @@ -20,6 +20,8 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC { if message?.isBounced == false { actions.append(pinMessageActionItem()) actions.append(translateActionItem()) + actions.append(reminderActionItem()) + actions.append(saveForLaterActionItem()) } if AppConfig.shared.demoAppConfig.isMessageDebuggerEnabled { @@ -83,6 +85,67 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC { ) } + func reminderActionItem() -> ChatMessageActionItem { + let hasReminder = message?.reminder != nil + return ReminderActionItem( + hasReminder: hasReminder, + action: { [weak self] _ in + guard let self = self else { return } + + let alertController = UIAlertController( + title: "Select Reminder Time", + message: "When would you like to be reminded?", + preferredStyle: .alert + ) + + let actions = [ + UIAlertAction(title: "2 Minute", style: .default) { _ in + let remindAt = Date().addingTimeInterval(120) + self.updateOrCreateReminder(remindAt: remindAt) + }, + UIAlertAction(title: "30 Minutes", style: .default) { _ in + let remindAt = Date().addingTimeInterval(30 * 60) + self.updateOrCreateReminder(remindAt: remindAt) + }, + UIAlertAction(title: "1 Hour", style: .default) { _ in + let remindAt = Date().addingTimeInterval(60 * 60) + self.updateOrCreateReminder(remindAt: remindAt) + }, + UIAlertAction(title: "Cancel", style: .cancel) { _ in + self.delegate?.chatMessageActionsVCDidFinish(self) + } + ] + actions.forEach { alertController.addAction($0) } + self.present(alertController, animated: true) + } + ) + } + + private func updateOrCreateReminder(remindAt: Date) { + if message?.reminder != nil { + messageController.updateReminder(remindAt: remindAt) + } else { + messageController.createReminder(remindAt: remindAt) + } + delegate?.chatMessageActionsVCDidFinish(self) + } + + func saveForLaterActionItem() -> ChatMessageActionItem { + let hasReminder = message?.reminder != nil + return SaveForLaterActionItem( + hasReminder: message?.reminder != nil, + action: { [weak self] _ in + guard let self = self else { return } + if hasReminder { + messageController.deleteReminder() + } else { + messageController.createReminder() + } + self.delegate?.chatMessageActionsVCDidFinish(self) + } + ) + } + func messageDebugActionItem() -> ChatMessageActionItem { MessageDebugActionItem { [weak self] _ in guard let message = self?.message else { return } @@ -145,4 +208,44 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC { var icon: UIImage { UIImage(systemName: "ladybug")! } var action: (ChatMessageActionItem) -> Void } + + struct ReminderActionItem: ChatMessageActionItem { + var title: String + var isDestructive: Bool { false } + let icon: UIImage + let action: (ChatMessageActionItem) -> Void + + init( + hasReminder: Bool, + action: @escaping (ChatMessageActionItem) -> Void + ) { + title = hasReminder ? "Update Reminder" : "Remind Me" + self.action = action + if hasReminder { + icon = UIImage(systemName: "clock.badge.checkmark") ?? .init() + } else { + icon = UIImage(systemName: "clock") ?? .init() + } + } + } + + struct SaveForLaterActionItem: ChatMessageActionItem { + var title: String + var isDestructive: Bool { false } + let icon: UIImage + let action: (ChatMessageActionItem) -> Void + + init( + hasReminder: Bool, + action: @escaping (ChatMessageActionItem) -> Void + ) { + title = hasReminder ? "Remove from later" : "Save for later" + self.action = action + if hasReminder { + icon = UIImage(systemName: "bookmark.fill") ?? .init() + } else { + icon = UIImage(systemName: "bookmark") ?? .init() + } + } + } } From 804e0121c13b5954a7bf5d08f7eef60c7360ef9a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Mar 2025 18:12:09 +0000 Subject: [PATCH 10/42] Add highlighted message view when saved for later in the demo app --- .../DemoChatMessageContentView.swift | 36 +++++++++++++++++++ ...DemoChatMessageLayoutOptionsResolver.swift | 5 +++ Sources/StreamChat/Models/ChatMessage.swift | 1 + .../StreamChat/Models/MessageReminder.swift | 2 +- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/DemoApp/StreamChat/Components/DemoChatMessageContentView.swift b/DemoApp/StreamChat/Components/DemoChatMessageContentView.swift index b40c715bab3..3aa07dab9eb 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageContentView.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageContentView.swift @@ -9,6 +9,33 @@ import UIKit final class DemoChatMessageContentView: ChatMessageContentView { var pinInfoLabel: UILabel? + lazy var saveForLaterView: UIView = { + HContainer(spacing: 4) { + saveForLaterIcon + .height(12) + .width(12) + saveForLaterLabel + .height(30) + } + }() + + lazy var saveForLaterIcon: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage(systemName: "bookmark.fill") + imageView.tintColor = appearance.colorPalette.accentPrimary + return imageView + }() + + lazy var saveForLaterLabel: UILabel = { + let label = UILabel() + label.text = "Saved for later" + label.translatesAutoresizingMaskIntoConstraints = false + label.font = appearance.fonts.footnote + label.textColor = appearance.colorPalette.accentPrimary + return label + }() + override func layout(options: ChatMessageLayoutOptions) { super.layout(options: options) @@ -19,6 +46,15 @@ final class DemoChatMessageContentView: ChatMessageContentView { pinInfoLabel?.textColor = appearance.colorPalette.textLowEmphasis bubbleThreadFootnoteContainer.insertArrangedSubview(pinInfoLabel!, at: 0) } + + if options.contains(.saveForLaterInfo) { + backgroundColor = appearance.colorPalette.highlightedAccentBackground1 + bubbleThreadFootnoteContainer.insertArrangedSubview(saveForLaterView, at: 0) + saveForLaterView.topAnchor.constraint( + equalTo: bubbleThreadFootnoteContainer.topAnchor, + constant: 4 + ).isActive = true + } } override func updateContent() { diff --git a/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift b/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift index 5c5399fd2e9..a5fb562c451 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift @@ -8,6 +8,7 @@ import StreamChatUI extension ChatMessageLayoutOption { static let pinInfo: Self = "pinInfo" + static let saveForLaterInfo: Self = "saveForLaterInfo" } final class DemoChatMessageLayoutOptionsResolver: ChatMessageLayoutOptionsResolver { @@ -28,6 +29,10 @@ final class DemoChatMessageLayoutOptionsResolver: ChatMessageLayoutOptionsResolv options.insert(.pinInfo) } + if message.reminder != nil { + options.insert(.saveForLaterInfo) + } + return options } } diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 8081092faf4..3ff4f61a822 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -444,6 +444,7 @@ extension ChatMessage: Hashable { guard lhs.translations == rhs.translations else { return false } guard lhs.type == rhs.type else { return false } guard lhs.draftReply == rhs.draftReply else { return false } + guard lhs.reminder == rhs.reminder else { return false } return true } diff --git a/Sources/StreamChat/Models/MessageReminder.swift b/Sources/StreamChat/Models/MessageReminder.swift index aa13467c258..6e76d5e87cf 100644 --- a/Sources/StreamChat/Models/MessageReminder.swift +++ b/Sources/StreamChat/Models/MessageReminder.swift @@ -56,7 +56,7 @@ extension MessageReminder: Hashable { /// /// Does not contain any reference to the message or channel so that /// it can be used in these models without creating a circular reference. -public struct MessageReminderInfo { +public struct MessageReminderInfo: Equatable { /// The date when the user should be reminded about this message. /// If nil, this is a bookmark type reminder without a notification. public let remindAt: Date? From d2e3e7bc44e1bdb06d1e26d2050b4d481608fb21 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Mar 2025 20:47:01 +0000 Subject: [PATCH 11/42] Add test coverage to Message Controller --- .../StreamChat/Workers/MessageUpdater.swift | 8 +- .../Unique/ChatMessage+Unique.swift | 3 +- .../ChatMessage_Mock.swift | 6 +- .../Database/DatabaseSession_Mock.swift | 8 + .../Workers/MessageUpdater_Mock.swift | 74 ++++++++ .../MessageController_Tests.swift | 171 ++++++++++++++++++ 6 files changed, 263 insertions(+), 7 deletions(-) diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index e8992d65609..cb501c29a13 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -1044,7 +1044,7 @@ class MessageUpdater: Worker { func deleteReminder( messageId: MessageId, cid: ChannelId, - completion: ((Error?) -> Void)? = nil + completion: @escaping ((Error?) -> Void) ) { let endpoint: Endpoint = .deleteReminder(messageId: messageId) @@ -1079,12 +1079,12 @@ class MessageUpdater: Worker { self?.apiClient.request(endpoint: endpoint) { result in switch result { case .success: - completion?(nil) + completion(nil) case .failure(let error): // Rollback the optimistic delete if the API call fails guard let originalPayload = originalPayload else { - completion?(error) + completion(error) return } @@ -1096,7 +1096,7 @@ class MessageUpdater: Worker { log.warning("Failed to rollback reminder deletion: \(error)") } }, completion: { _ in - completion?(error) + completion(error) }) } } diff --git a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift index 246c4612360..228f021f63c 100644 --- a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift +++ b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift @@ -53,7 +53,8 @@ extension ChatMessage { readBy: [], poll: nil, textUpdatedAt: nil, - draftReply: nil + draftReply: nil, + reminder: nil ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift index 8851e5383eb..ac865de9882 100644 --- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift @@ -50,7 +50,8 @@ public extension ChatMessage { underlyingContext: NSManagedObjectContext? = nil, textUpdatedAt: Date? = nil, poll: Poll? = nil, - draftReply: DraftMessage? = nil + draftReply: DraftMessage? = nil, + reminder: MessageReminderInfo? = nil ) -> Self { .init( id: id, @@ -91,7 +92,8 @@ public extension ChatMessage { readBy: readBy, poll: poll, textUpdatedAt: textUpdatedAt, - draftReply: draftReply + draftReply: draftReply, + reminder: reminder ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 8491c8dcf4e..3ade71f4880 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -62,6 +62,14 @@ class DatabaseSession_Mock: DatabaseSession { return try underlyingSession.saveQuery(query: query) } + func saveReminder(payload: ReminderPayload, cache: PreWarmedCache?) throws -> MessageReminderDTO { + return try underlyingSession.saveReminder(payload: payload, cache: cache) + } + + func deleteReminder(messageId: MessageId) { + underlyingSession.deleteReminder(messageId: messageId) + } + func saveChannel( payload: ChannelDetailPayload, query: ChannelListQuery?, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift index afa56d35bbe..45006d8845e 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift @@ -133,6 +133,23 @@ final class MessageUpdater_Mock: MessageUpdater { var loadThread_query: ThreadQuery? var loadThread_completion: ((Result) -> Void)? + @Atomic var createReminder_messageId: MessageId? + @Atomic var createReminder_cid: ChannelId? + @Atomic var createReminder_remindAt: Date? + @Atomic var createReminder_completion: ((Result) -> Void)? + @Atomic var createReminder_completion_result: Result? + + @Atomic var updateReminder_messageId: MessageId? + @Atomic var updateReminder_cid: ChannelId? + @Atomic var updateReminder_remindAt: Date? + @Atomic var updateReminder_completion: ((Result) -> Void)? + @Atomic var updateReminder_completion_result: Result? + + @Atomic var deleteReminder_messageId: MessageId? + @Atomic var deleteReminder_cid: ChannelId? + @Atomic var deleteReminder_completion: ((Error?) -> Void)? + @Atomic var deleteReminder_error: Error? + // Cleans up all recorded values func cleanUp() { getMessage_cid = nil @@ -247,6 +264,23 @@ final class MessageUpdater_Mock: MessageUpdater { loadThread_query = nil loadThread_completion = nil + + createReminder_messageId = nil + createReminder_cid = nil + createReminder_remindAt = nil + createReminder_completion = nil + createReminder_completion_result = nil + + updateReminder_messageId = nil + updateReminder_cid = nil + updateReminder_remindAt = nil + updateReminder_completion = nil + updateReminder_completion_result = nil + + deleteReminder_messageId = nil + deleteReminder_cid = nil + deleteReminder_completion = nil + deleteReminder_error = nil } override func getMessage(cid: ChannelId, messageId: MessageId, completion: ((Result) -> Void)? = nil) { @@ -516,6 +550,46 @@ final class MessageUpdater_Mock: MessageUpdater { loadThread_query = query loadThread_completion = completion } + + override func createReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + createReminder_messageId = messageId + createReminder_cid = cid + createReminder_remindAt = remindAt + createReminder_completion = completion + + if let result = createReminder_completion_result { + completion(result) + } + } + + override func updateReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + updateReminder_messageId = messageId + updateReminder_cid = cid + updateReminder_remindAt = remindAt + updateReminder_completion = completion + + if let result = updateReminder_completion_result { + completion(result) + } + } + + override func deleteReminder(messageId: MessageId, cid: ChannelId, completion: @escaping (((any Error)?) -> Void)) { + deleteReminder_messageId = messageId + deleteReminder_cid = cid + deleteReminder_completion = completion + + completion(deleteReminder_error) + } } extension MessageUpdater.MessageSearchResults { diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index 6156818365c..ed5229dfa3b 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -2583,6 +2583,177 @@ final class MessageController_Tests: XCTestCase { delegate.didChangeRepliesExpectedCount = count wait(for: [expectation], timeout: defaultTimeout) } + + // MARK: - Reminder Methods + + func test_createReminder_propagatesSuccessResult() { + // Prepare data for mocking + let remindAt = Date() + let reminderResponse = MessageReminder( + id: messageId, + remindAt: remindAt, + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + ) + + // Setup mock response from message updater + env.messageUpdater?.createReminder_completion_result = .success(reminderResponse) + + // Setup callback verification + let exp = expectation(description: "createReminder callback should be called") + var receivedResult: Result? + + // Call method being tested + controller.createReminder(remindAt: remindAt) { result in + receivedResult = result + exp.fulfill() + } + + // Wait for callback + wait(for: [exp], timeout: defaultTimeout) + + // Assert messageUpdater is called with correct params + XCTAssertNotNil(env.messageUpdater) + XCTAssertEqual(env.messageUpdater?.createReminder_messageId, messageId) + XCTAssertEqual(env.messageUpdater?.createReminder_cid, cid) + XCTAssertEqual(env.messageUpdater?.createReminder_remindAt, remindAt) + XCTAssertEqual(receivedResult?.value?.id, reminderResponse.id) + } + + func test_createReminder_propagatesFailureResult() { + // Setup mock error response from message updater + let testError = TestError() + env.messageUpdater?.createReminder_completion_result = .failure(testError) + + // Setup callback verification + let exp = expectation(description: "createReminder callback should be called") + var receivedError: Error? + + // Call method being tested + controller.createReminder(remindAt: nil) { result in + if case let .failure(error) = result { + receivedError = error + } + exp.fulfill() + } + + // Wait for callback + wait(for: [exp], timeout: defaultTimeout) + + // Assert callback is called with correct error + XCTAssertEqual(receivedError as? TestError, testError) + } + + func test_updateReminder_propagatesSuccessResult() { + // Prepare data for mocking + let remindAt = Date() + let reminderResponse = MessageReminder( + id: messageId, + remindAt: remindAt, + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + ) + + // Setup mock response from message updater + env.messageUpdater?.updateReminder_completion_result = .success(reminderResponse) + + // Setup callback verification + let exp = expectation(description: "updateReminder callback should be called") + var receivedResult: Result? + + // Call method being tested + controller.updateReminder(remindAt: remindAt) { result in + receivedResult = result + exp.fulfill() + } + + // Wait for callback + wait(for: [exp], timeout: defaultTimeout) + + // Assert messageUpdater is called with correct params + XCTAssertNotNil(env.messageUpdater) + XCTAssertEqual(env.messageUpdater?.updateReminder_messageId, messageId) + XCTAssertEqual(env.messageUpdater?.updateReminder_cid, cid) + XCTAssertEqual(env.messageUpdater?.updateReminder_remindAt, remindAt) + + // Assert callback is called on the callback queue + XCTAssertEqual(receivedResult?.value?.id, reminderResponse.id) + } + + func test_updateReminder_propagatesFailureResult() { + // Setup mock error response from message updater + let testError = TestError() + env.messageUpdater?.updateReminder_completion_result = .failure(testError) + + // Setup callback verification + let exp = expectation(description: "updateReminder callback should be called") + var receivedError: Error? + + // Call method being tested + controller.updateReminder(remindAt: nil) { result in + if case let .failure(error) = result { + receivedError = error + } + exp.fulfill() + } + + // Wait for callback + wait(for: [exp], timeout: defaultTimeout) + + // Assert callback is called with correct error + XCTAssertEqual(receivedError as? TestError, testError) + } + + func test_deleteReminder_propagatesSuccess() { + // Setup mock response from message updater + env.messageUpdater?.deleteReminder_error = nil + + // Setup callback verification + let exp = expectation(description: "deleteReminder callback should be called") + var receivedError: Error? + + // Call method being tested + controller.deleteReminder { error in + receivedError = error + exp.fulfill() + } + + // Wait for callback + wait(for: [exp], timeout: defaultTimeout) + + // Assert messageUpdater is called with correct params + XCTAssertEqual(env.messageUpdater?.deleteReminder_messageId, messageId) + XCTAssertEqual(env.messageUpdater?.deleteReminder_cid, cid) + + // Assert callback is called with nil error on success + XCTAssertNil(receivedError) + } + + func test_deleteReminder_propagatesError() { + // Setup mock error response from message updater + let testError = TestError() + env.messageUpdater?.deleteReminder_error = testError + + // Setup callback verification + let exp = expectation(description: "deleteReminder callback should be called") + var receivedError: Error? + + // Call method being tested + controller.deleteReminder { error in + receivedError = error + exp.fulfill() + } + + // Wait for callback + wait(for: [exp], timeout: defaultTimeout) + + // Assert callback is called with the error + XCTAssertEqual(receivedError as? TestError, testError) + } } private class TestDelegate: QueueAwareDelegate, ChatMessageControllerDelegate { From bfd833d2cb6af9dfd34cd60e227b8b467fa5ff6a Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 18 Mar 2025 23:54:38 +0000 Subject: [PATCH 12/42] Add test coverage to MessagUpdater --- .../StreamChat/Workers/MessageUpdater.swift | 8 +- .../Workers/MessageUpdater_Tests.swift | 465 ++++++++++++++++++ 2 files changed, 469 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index cb501c29a13..538ce3e90f1 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -947,13 +947,13 @@ class MessageUpdater: Worker { } catch { log.warning("Failed to optimistically create reminder in the database: \(error)") } - } completion: { [weak self] _ in + } completion: { _ in // Make the API call to create the reminder - self?.apiClient.request(endpoint: endpoint) { result in + self.apiClient.request(endpoint: endpoint) { result in switch result { case .success(let payload): var reminder: MessageReminder! - self?.database.write({ session in + self.database.write({ session in let messageReminder = payload.reminder reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() }, completion: { error in @@ -965,7 +965,7 @@ class MessageUpdater: Worker { }) case .failure(let error): // Rollback the optimistic update if the API call fails - self?.database.write({ session in + self.database.write({ session in session.deleteReminder(messageId: messageId) }, completion: { _ in completion(.failure(error)) diff --git a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift index a9659877729..9c3407c312d 100644 --- a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift @@ -2974,6 +2974,471 @@ final class MessageUpdater_Tests: XCTestCase { wait(for: [exp], timeout: defaultTimeout) } + + // MARK: - Message Reminders + + func test_createReminder_makesCorrectAPICall() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date() + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `createReminder` call + let exp = expectation(description: "completion is called") + messageUpdater.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { _ in + exp.fulfill() + } + + apiClient.test_mockResponseResult(.success(ReminderResponsePayload( + reminder: .init( + channelCid: cid, + messageId: messageId, + remindAt: remindAt, + createdAt: .unique, + updatedAt: .unique + ) + ))) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .createReminder( + messageId: messageId, + request: ReminderRequestBody(remindAt: remindAt) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_createReminder_updatesLocalMessageOptimistically() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date() + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `createReminder` call + messageUpdater.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { _ in } + + // Assert reminder was created locally + var expectedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + expectedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(expectedRemindAt, remindAt) + } + + func test_createReminder_rollsBackOnFailure() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date() + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `createReminder` call + let exp = expectation(description: "completion is called") + messageUpdater.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { _ in + exp.fulfill() + } + + // Assert reminder was created locally + var expectedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + expectedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(expectedRemindAt, remindAt) + + apiClient.test_simulateResponse(Result.failure(TestError())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert reminder was rolled back + var actualRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + actualRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNil(actualRemindAt) + } + + func test_updateReminder_makesCorrectAPICall() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message with an existing reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `updateReminder` call + let exp = expectation(description: "completion is called") + messageUpdater.updateReminder( + messageId: messageId, + cid: cid, + remindAt: newRemindAt + ) { _ in + exp.fulfill() + } + + apiClient.test_mockResponseResult(.success(ReminderResponsePayload( + reminder: .init( + channelCid: cid, + messageId: messageId, + remindAt: newRemindAt, + createdAt: .unique, + updatedAt: .unique + ) + ))) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .updateReminder( + messageId: messageId, + request: ReminderRequestBody(remindAt: newRemindAt) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_updateReminder_updatesLocalMessageOptimistically() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message with an existing reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `updateReminder` call + messageUpdater.updateReminder( + messageId: messageId, + cid: cid, + remindAt: newRemindAt + ) { _ in } + + // Assert reminder was updated locally (optimistically) + var updatedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + updatedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(updatedRemindAt, newRemindAt) + } + + func test_updateReminder_rollsBackOnFailure() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let originalRemindAt = Date().addingTimeInterval(-3600) // 1 hour ago + let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message with an existing reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: originalRemindAt, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `updateReminder` call + let exp = expectation(description: "completion is called") + messageUpdater.updateReminder( + messageId: messageId, + cid: cid, + remindAt: newRemindAt + ) { _ in + exp.fulfill() + } + + // Assert reminder was updated locally (optimistically) + var updatedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + updatedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(updatedRemindAt, newRemindAt) + + // Simulate API failure + apiClient.test_simulateResponse(Result.failure(TestError())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert reminder was rolled back to original state + var rolledBackRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + rolledBackRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(rolledBackRemindAt, originalRemindAt) + } + + func test_deleteReminder_makesCorrectAPICall() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message with reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + let reminderDTO = session.message(id: messageId)?.reminder + messageDTO.reminder = reminderDTO + } + + // Simulate `deleteReminder` call + let exp = expectation(description: "completion is called") + messageUpdater.deleteReminder( + messageId: messageId, + cid: cid + ) { _ in + exp.fulfill() + } + + apiClient.test_mockResponseResult(.success(EmptyResponse())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .deleteReminder(messageId: messageId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_deleteReminder_deletesLocalReminderOptimistically() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message with reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Verify reminder exists before deletion + var hasReminderBefore = false + try database.writeSynchronously { session in + hasReminderBefore = session.message(id: messageId)?.reminder != nil + } + XCTAssertTrue(hasReminderBefore, "Message should have a reminder before deletion") + + // Simulate `deleteReminder` call + messageUpdater.deleteReminder( + messageId: messageId, + cid: cid + ) { _ in } + + // Assert reminder was deleted locally (optimistically) + var hasReminderAfter = true + try database.writeSynchronously { session in + hasReminderAfter = session.message(id: messageId)?.reminder != nil + } + XCTAssertFalse(hasReminderAfter, "Reminder should be optimistically deleted locally") + } + + func test_deleteReminder_rollsBackOnFailure() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let currentUserId: UserId = .unique + + // Create current user in the database + try database.createCurrentUser(id: currentUserId) + + // Create a channel in the database + try database.createChannel(cid: cid) + + // Create a message with reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Store original reminder values for later comparison + var originalRemindAt: Date? + var originalCreatedAt: Date? + var originalUpdatedAt: Date? + + try database.writeSynchronously { session in + guard let reminder = session.message(id: messageId)?.reminder else { return } + originalRemindAt = reminder.remindAt?.bridgeDate + originalCreatedAt = reminder.createdAt.bridgeDate + originalUpdatedAt = reminder.updatedAt.bridgeDate + } + + // Simulate `deleteReminder` call + let exp = expectation(description: "completion is called") + messageUpdater.deleteReminder( + messageId: messageId, + cid: cid + ) { _ in + exp.fulfill() + } + + // Verify reminder was optimistically deleted + var hasReminderAfterDelete = true + try database.writeSynchronously { session in + hasReminderAfterDelete = session.message(id: messageId)?.reminder != nil + } + XCTAssertFalse(hasReminderAfterDelete, "Reminder should be optimistically deleted") + + // Simulate API failure + apiClient.test_simulateResponse(Result.failure(TestError())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert reminder was restored with original values + var restoredRemindAt: Date? + var restoredCreatedAt: Date? + var restoredUpdatedAt: Date? + + try database.writeSynchronously { session in + guard let reminder = session.message(id: messageId)?.reminder else { + XCTFail("Reminder should be restored after API failure") + return + } + + restoredRemindAt = reminder.remindAt?.bridgeDate + restoredCreatedAt = reminder.createdAt.bridgeDate + restoredUpdatedAt = reminder.updatedAt.bridgeDate + } + + XCTAssertNearlySameDate(restoredRemindAt, originalRemindAt) + XCTAssertNearlySameDate(restoredCreatedAt, originalCreatedAt) + XCTAssertNearlySameDate(restoredUpdatedAt, originalUpdatedAt) + } } // MARK: - Helpers From 3063d9ea4f2abb97436689990b58e2f919a9e40b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Mar 2025 01:07:05 +0000 Subject: [PATCH 13/42] Add test coverage to ReminderPayload parsing --- .../Endpoints/Payloads/MessagePayloads.swift | 4 + .../Database/DTOs/MessageReminderDTO.swift | 2 +- StreamChat.xcodeproj/project.pbxproj | 4 + .../Fixtures/JSONs/ReminderPayload.json | 112 ++++++++++++++++++ .../Payloads/MessagePayloads_Tests.swift | 107 +++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 TestTools/StreamChatTestTools/Fixtures/JSONs/ReminderPayload.json diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 9cc8bf5e70c..18476fb51ac 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -390,6 +390,7 @@ public struct Command: Codable, Hashable { /// An object describing a reminder JSON payload. struct ReminderPayload: Decodable { let channelCid: ChannelId + let channel: ChannelDetailPayload? let messageId: MessageId let message: MessagePayload? let remindAt: Date? @@ -400,6 +401,7 @@ struct ReminderPayload: Decodable { channelCid: ChannelId, messageId: MessageId, message: MessagePayload? = nil, + channel: ChannelDetailPayload? = nil, remindAt: Date?, createdAt: Date, updatedAt: Date @@ -407,6 +409,7 @@ struct ReminderPayload: Decodable { self.channelCid = channelCid self.messageId = messageId self.message = message + self.channel = channel self.remindAt = remindAt self.createdAt = createdAt self.updatedAt = updatedAt @@ -416,6 +419,7 @@ struct ReminderPayload: Decodable { case channelCid = "channel_cid" case messageId = "message_id" case message + case channel case remindAt = "remind_at" case createdAt = "created_at" case updatedAt = "updated_at" diff --git a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift index 75cb75acfe3..52ab4c8f305 100644 --- a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift @@ -84,7 +84,7 @@ extension NSManagedObjectContext: ReminderDatabaseSession { let channelDTO: ChannelDTO if let existingChannel = ChannelDTO.load(cid: payload.channelCid, context: self) { channelDTO = existingChannel - } else if let channelPayload = payload.message?.channel { + } else if let channelPayload = payload.channel { channelDTO = try saveChannel(payload: channelPayload, query: nil, cache: nil) } else { throw ClientError.ChannelDoesNotExist(cid: payload.channelCid) diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 1531edc2e4c..34e08760438 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1691,6 +1691,7 @@ ADB8B8EB2D8890B900549C95 /* MessageReminderDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */; }; ADB8B8ED2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; ADB8B8EE2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; + ADB8B8F02D8A493900549C95 /* ReminderPayload.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */; }; ADB951A1291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951AB291C1DE400800554 /* AttachmentUploader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */; }; @@ -4408,6 +4409,7 @@ ADB4165026208C7900E623E3 /* AttachmentPreviewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewProvider.swift; sourceTree = ""; }; ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderDTO.swift; sourceTree = ""; }; ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder.swift; sourceTree = ""; }; + ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderPayload.json; sourceTree = ""; }; ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader_Spy.swift; sourceTree = ""; }; ADB951AC291C22DB00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; @@ -8151,6 +8153,7 @@ A3C729552840BA4800FFE8B4 /* JSONs */ = { isa = PBXGroup; children = ( + ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */, AD545E6C2D565316008FD399 /* DraftMessage.json */, 798779F72498E47700015F8B /* Channel.json */, AD6E32972BBB13650073831B /* Thread.json */, @@ -10291,6 +10294,7 @@ A311B3E527E8B98C00CFCF6D /* MessageWithBrokenAttachments.json in Resources */, A311B3ED27E8B99800CFCF6D /* ChannelHidden+HistoryCleared.json in Resources */, A3D9D68827EDE3B900725066 /* chewbacca.jpg in Resources */, + ADB8B8F02D8A493900549C95 /* ReminderPayload.json in Resources */, A311B3CE27E8B98C00CFCF6D /* CurrentUserCustomRole.json in Resources */, A311B3D227E8B98C00CFCF6D /* UserPayload.json in Resources */, A311B3EE27E8B99800CFCF6D /* ChannelTruncated.json in Resources */, diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/ReminderPayload.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/ReminderPayload.json new file mode 100644 index 00000000000..86757bf4a4f --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/ReminderPayload.json @@ -0,0 +1,112 @@ +{ + "channel": { + "auto_translation_language": "", + "cid": "messaging:26D82FB1-5", + "config": { + "automod": "disabled", + "automod_behavior": "flag", + "commands": [], + "connect_events": true, + "created_at": "2025-03-14T09:56:35.247111552Z", + "custom_events": true, + "mark_messages_pending": false, + "max_message_length": 5000, + "message_retention": "infinite", + "mutes": true, + "name": "messaging", + "polls": false, + "push_notifications": true, + "quotes": true, + "reactions": true, + "read_events": true, + "reminders": false, + "replies": true, + "search": true, + "skip_last_msg_update_for_system_msgs": false, + "typing_events": true, + "updated_at": "2025-03-14T09:56:35.247111667Z", + "uploads": true, + "url_enrichment": true, + "user_message_reminders": true + }, + "created_at": "2025-02-25T10:29:54.133746Z", + "created_by": { + "banned": false, + "birthland": "Corellia", + "created_at": "2024-07-09T10:25:12.255599Z", + "id": "han_solo", + "image": "https://vignette.wikia.nocookie.net/starwars/images/e/e2/TFAHanSolo.png", + "last_active": "2025-03-19T00:33:30.712641Z", + "last_engaged_at": "2025-03-19T00:04:12.127289Z", + "name": "Han Solo", + "online": false, + "role": "user", + "updated_at": "2025-02-25T13:47:31.92961Z" + }, + "disabled": false, + "frozen": false, + "id": "26D82FB1-5", + "last_message_at": "2025-03-07T14:54:03.383299Z", + "member_count": 3, + "name": "Yo", + "type": "messaging", + "updated_at": "2025-02-25T10:29:54.133746Z" + }, + "channel_cid": "messaging:26D82FB1-5", + "created_at": "2025-03-19T00:38:38.697482729Z", + "message": { + "attachments": [], + "cid": "messaging:26D82FB1-5", + "created_at": "2025-03-04T14:33:10.628163Z", + "deleted_reply_count": 0, + "html": "

4

\n", + "id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", + "latest_reactions": [], + "mentioned_users": [], + "own_reactions": [], + "pin_expires": null, + "pinned": false, + "pinned_at": null, + "pinned_by": null, + "reaction_counts": {}, + "reaction_groups": null, + "reaction_scores": {}, + "reply_count": 0, + "restricted_visibility": [], + "shadowed": false, + "silent": false, + "text": "4", + "type": "regular", + "updated_at": "2025-03-04T14:33:10.628163Z", + "user": { + "banned": false, + "birthland": "Socorro", + "created_at": "2025-02-07T16:49:34.490544Z", + "id": "lando_calrissian", + "image": "./static/media/photo-1546820389-44d77e1f3b31.879865aecaba94eb1e8d.jpeg", + "last_active": "2025-03-19T00:11:43.92248973Z", + "last_engaged_at": "2025-03-18T00:22:43.872028Z", + "name": "lando_calrissian", + "online": false, + "role": "user", + "updated_at": "2025-03-14T17:35:12.761069Z" + } + }, + "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", + "remind_at": null, + "updated_at": "2025-03-19T00:38:38.697482729Z", + "user": { + "banned": false, + "birthland": "Corellia", + "created_at": "2024-07-09T10:25:12.255599Z", + "id": "han_solo", + "image": "https://vignette.wikia.nocookie.net/starwars/images/e/e2/TFAHanSolo.png", + "last_active": "2025-03-19T00:33:30.712641Z", + "last_engaged_at": "2025-03-19T00:04:12.127289Z", + "name": "Han Solo", + "online": false, + "role": "user", + "updated_at": "2025-02-25T13:47:31.92961Z" + }, + "user_id": "han_solo" +} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift index 65e35c8dc8a..7bebf950796 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift @@ -259,3 +259,110 @@ final class MessageReactionsPayload_Tests: XCTestCase { XCTAssertTrue(payload.reactions.count == 2) } } + +final class ReminderPayload_Tests: XCTestCase { + let reminderJSON = XCTestCase.mockData(fromJSONFile: "ReminderPayload") + + func test_reminderPayload_isSerialized() throws { + let payload = try JSONDecoder.default.decode(ReminderPayload.self, from: reminderJSON) + + // Test basic properties + XCTAssertEqual(payload.channelCid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertNil(payload.remindAt) // Updated to nil as per new JSON + XCTAssertEqual(payload.createdAt, "2025-03-19T00:38:38.697482729Z".toDate()) + XCTAssertEqual(payload.updatedAt, "2025-03-19T00:38:38.697482729Z".toDate()) + + // Test embedded message + XCTAssertNotNil(payload.message) + XCTAssertEqual(payload.message?.id, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertEqual(payload.message?.text, "4") + XCTAssertEqual(payload.message?.type.rawValue, "regular") + XCTAssertEqual(payload.message?.user.id, "lando_calrissian") + XCTAssertEqual(payload.message?.createdAt, "2025-03-04T14:33:10.628163Z".toDate()) + XCTAssertEqual(payload.message?.updatedAt, "2025-03-04T14:33:10.628163Z".toDate()) + + // Test channel properties (new in updated JSON) + XCTAssertNotNil(payload.channel) + XCTAssertEqual(payload.channel?.cid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.channel?.name, "Yo") + } +} + +final class ReminderResponsePayload_Tests: XCTestCase { + func test_isSerialized() throws { + // Create a JSON representation of a ReminderResponsePayload + // with the updated structure including duration + let reminderResponseJSON = """ + { + "duration": "30.74ms", + "reminder": { + "channel_cid": "messaging:26D82FB1-5", + "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", + "remind_at": null, + "created_at": "2025-03-19T00:38:38.697482729Z", + "updated_at": "2025-03-19T00:38:38.697482729Z", + "user_id": "han_solo" + } + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(ReminderResponsePayload.self, from: reminderResponseJSON) + + XCTAssertEqual(payload.reminder.channelCid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.reminder.messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertNil(payload.reminder.remindAt) + XCTAssertEqual(payload.reminder.createdAt, "2025-03-19T00:38:38.697482729Z".toDate()) + XCTAssertEqual(payload.reminder.updatedAt, "2025-03-19T00:38:38.697482729Z".toDate()) + } +} + +final class RemindersQueryPayload_Tests: XCTestCase { + func test_isSerialized() throws { + // Create a JSON representation of a RemindersQueryPayload with updated structure + let remindersQueryJSON = """ + { + "duration": "30.74ms", + "reminders": [ + { + "channel_cid": "messaging:26D82FB1-5", + "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", + "remind_at": null, + "created_at": "2025-03-19T00:38:38.697482729Z", + "updated_at": "2025-03-19T00:38:38.697482729Z", + "user_id": "han_solo" + }, + { + "channel_cid": "messaging:456", + "message_id": "message-456", + "remind_at": "2023-02-01T12:00:00.000Z", + "created_at": "2022-02-03T00:00:00.000Z", + "updated_at": "2022-02-03T00:00:00.000Z", + "user_id": "luke_skywalker" + } + ], + "next": "next-page-token", + "prev": "prev-page-token" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(RemindersQueryPayload.self, from: remindersQueryJSON) + + // Verify the count of reminders + XCTAssertEqual(payload.reminders.count, 2) + + // Verify pagination tokens + XCTAssertEqual(payload.next, "next-page-token") + XCTAssertEqual(payload.prev, "prev-page-token") + + // Verify first reminder details + XCTAssertEqual(payload.reminders[0].channelCid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.reminders[0].messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertNil(payload.reminders[0].remindAt) + + // Verify second reminder details + XCTAssertEqual(payload.reminders[1].channelCid.rawValue, "messaging:456") + XCTAssertEqual(payload.reminders[1].messageId, "message-456") + XCTAssertEqual(payload.reminders[1].remindAt, "2023-02-01T12:00:00.000Z".toDate()) + } +} From 12c3a08c7bd66144d479c706393c43b4cb1207d0 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Mar 2025 14:52:49 +0000 Subject: [PATCH 14/42] Expose `Filter.isNil` that wraps the`.exists` filter --- Sources/StreamChat/Query/Filter.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/StreamChat/Query/Filter.swift b/Sources/StreamChat/Query/Filter.swift index aa3a633a661..eeeed7d4998 100644 --- a/Sources/StreamChat/Query/Filter.swift +++ b/Sources/StreamChat/Query/Filter.swift @@ -427,6 +427,17 @@ public extension Filter { ) } + /// Matches values where the given property is nil. + static func isNil(_ key: FilterKey) -> Filter { + .init( + operator: .exists, + key: key, + value: false, + valueMapper: key.valueMapper, + keyPathString: key.keyPathString + ) + } + /// Matches if the key contains the given value. static func contains(_ key: FilterKey, value: String) -> Filter { .init( From 6396538aa3386cfaf33dc2f0214bb89647332589 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Mar 2025 15:30:25 +0000 Subject: [PATCH 15/42] Rename `Filter+ChatChannel` to `Filter+predicate` so that it can be used everywhere --- ...lter+ChatChannel.swift => Filter+predicate.swift} | 2 +- StreamChat.xcodeproj/project.pbxproj | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) rename Sources/StreamChat/Query/{Filter+ChatChannel.swift => Filter+predicate.swift} (99%) diff --git a/Sources/StreamChat/Query/Filter+ChatChannel.swift b/Sources/StreamChat/Query/Filter+predicate.swift similarity index 99% rename from Sources/StreamChat/Query/Filter+ChatChannel.swift rename to Sources/StreamChat/Query/Filter+predicate.swift index 63b258b5421..408145a0bd0 100644 --- a/Sources/StreamChat/Query/Filter+ChatChannel.swift +++ b/Sources/StreamChat/Query/Filter+predicate.swift @@ -4,7 +4,7 @@ import Foundation -extension Filter where Scope == ChannelListFilterScope { +extension Filter { /// If a valueMapper was provided, then here we will try to transform the value /// using the mapper. /// diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 34e08760438..fe295725caa 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -2462,8 +2462,8 @@ C1FC2F8C27416E1F0062530F /* UIImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D4273932D200F93B34 /* UIImage+SwiftyGif.swift */; }; C1FC2F8D27416E1F0062530F /* NSImageView+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D6273932D200F93B34 /* NSImageView+SwiftyGif.swift */; }; C1FC2F8E27416E1F0062530F /* NSImage+SwiftyGif.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13C74D3273932D200F93B34 /* NSImage+SwiftyGif.swift */; }; - C1FFD9F927ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */; }; - C1FFD9FA27ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */; }; + C1FFD9F927ECC7C7008A6848 /* Filter+predicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FFD9F827ECC7C7008A6848 /* Filter+predicate.swift */; }; + C1FFD9FA27ECC7C7008A6848 /* Filter+predicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FFD9F827ECC7C7008A6848 /* Filter+predicate.swift */; }; CF01EB7B288A2B7200B426B8 /* ChatChannelListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF01EB7A288A2B7200B426B8 /* ChatChannelListLoadingView.swift */; }; CF01EB7C288A2B7200B426B8 /* ChatChannelListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF01EB7A288A2B7200B426B8 /* ChatChannelListLoadingView.swift */; }; CF14397D2886374900898ECA /* ChatChannelListLoadingViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF14397C2886374900898ECA /* ChatChannelListLoadingViewCell.swift */; }; @@ -4648,7 +4648,7 @@ C1EE53A827BA662B00B1A6CA /* QueuedRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedRequestDTO.swift; sourceTree = ""; }; C1EFF3F2285E459C0057B91B /* IdentifiableModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiableModel.swift; sourceTree = ""; }; C1EFF3F728633B5D0057B91B /* IdentifiableModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiableModel_Tests.swift; sourceTree = ""; }; - C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Filter+ChatChannel.swift"; sourceTree = ""; }; + C1FFD9F827ECC7C7008A6848 /* Filter+predicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Filter+predicate.swift"; sourceTree = ""; }; CF01EB7A288A2B7200B426B8 /* ChatChannelListLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelListLoadingView.swift; sourceTree = ""; }; CF14397C2886374900898ECA /* ChatChannelListLoadingViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelListLoadingViewCell.swift; sourceTree = ""; }; CF14397F288637AD00898ECA /* ChatChannelListLoadingViewCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelListLoadingViewCellContentView.swift; sourceTree = ""; }; @@ -5674,7 +5674,7 @@ isa = PBXGroup; children = ( 792A4F432480107A00EAF71D /* Filter.swift */, - C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */, + C1FFD9F827ECC7C7008A6848 /* Filter+predicate.swift */, 792A4F4C248011E500EAF71D /* ChannelListQuery.swift */, 882C5745252C6FDF00E60C44 /* ChannelMemberListQuery.swift */, 792A4F422480107A00EAF71D /* ChannelQuery.swift */, @@ -11559,7 +11559,7 @@ 7908829C2546D95A00896F03 /* FlagMessagePayload.swift in Sources */, DA0BB1612513B5F200CAEFBD /* StringInterpolation+Extensions.swift in Sources */, 64C8C86E26934C6100329F82 /* UserInfo.swift in Sources */, - C1FFD9F927ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */, + C1FFD9F927ECC7C7008A6848 /* Filter+predicate.swift in Sources */, 4F1BEE7C2BE3851200B6685C /* ReactionListState+Observer.swift in Sources */, AD9632E12C0A43630073B814 /* ThreadUpdaterMiddleware.swift in Sources */, 799C9479247E3DEA001F1104 /* StreamChatModel.xcdatamodeld in Sources */, @@ -12686,7 +12686,7 @@ 4F427F672BA2F43200D92238 /* ConnectedUser.swift in Sources */, AD37D7C52BC979B000800D8C /* ThreadDTO.swift in Sources */, 40789D1829F6AC500018C2BB /* AudioPlaybackContextAccessor.swift in Sources */, - C1FFD9FA27ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */, + C1FFD9FA27ECC7C7008A6848 /* Filter+predicate.swift in Sources */, 4FE6E1AE2BAC7A1B00C80AF1 /* UserListState+Observer.swift in Sources */, 4FE6E1AB2BAC79F400C80AF1 /* MemberListState+Observer.swift in Sources */, AD8FEE592AA8E1A100273F88 /* ChatClient+Environment.swift in Sources */, From 3ecfff35b7758256336ab3971afe6c1b94fee079 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Mar 2025 18:50:28 +0000 Subject: [PATCH 16/42] Implementation of the Message Reminder Query --- .../Endpoints/Payloads/MessagePayloads.swift | 1 - .../StreamChat/ChatClient+Environment.swift | 7 + Sources/StreamChat/ChatClient.swift | 11 +- .../CurrentUserController.swift | 133 +++++++++++++++++- .../Database/DTOs/MessageReminderDTO.swift | 50 ++++++- .../StreamChatModel.xcdatamodel/contents | 1 + .../Query/MessageReminderListQuery.swift | 69 +++++---- .../Repositories/ReminderRepository.swift | 59 ++++++++ StreamChat.xcodeproj/project.pbxproj | 6 + 9 files changed, 295 insertions(+), 42 deletions(-) create mode 100644 Sources/StreamChat/Repositories/ReminderRepository.swift diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 18476fb51ac..8a4e04db132 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -445,7 +445,6 @@ struct ReminderRequestBody: Encodable { struct RemindersQueryPayload: Decodable { let reminders: [ReminderPayload] let next: String? - let prev: String? } /// A response containing a single reminder diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 3fa6d723ce6..fa8908cde5d 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -146,6 +146,13 @@ extension ChatClient { DraftMessagesRepository(database: $0, apiClient: $1) } + var reminderRepositoryBuilder: ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> ReminderRepository = { + ReminderRepository(database: $0, apiClient: $1) + } + var channelListUpdaterBuilder: ( _ database: DatabaseContainer, _ apiClient: APIClient diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 598967f5e19..6651658d1ba 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -78,7 +78,15 @@ public class ChatClient { let pollsRepository: PollsRepository - let draftMessagesRepository: DraftMessagesRepository + /// Repository for handling draft messages + lazy var draftMessagesRepository: DraftMessagesRepository = { + environment.draftMessagesRepositoryBuilder(databaseContainer, apiClient) + }() + + /// Repository for handling message reminders + lazy var reminderRepository: ReminderRepository = { + environment.reminderRepositoryBuilder(databaseContainer, apiClient) + }() let channelListUpdater: ChannelListUpdater @@ -210,7 +218,6 @@ public class ChatClient { apiClient ) pollsRepository = environment.pollsRepositoryBuilder(databaseContainer, apiClient) - draftMessagesRepository = environment.draftMessagesRepositoryBuilder(databaseContainer, apiClient) authRepository.delegate = self apiClientEncoder.connectionDetailsProviderDelegate = self diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 6ef29d6bd3f..e8b375d7d70 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -85,6 +85,8 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt /// The worker used to update the current user member for a given channel. private lazy var currentMemberUpdater = createMemberUpdater() + // MARK: - Drafts Properties + /// The query used for fetching the draft messages. private var draftListQuery = DraftListQuery() @@ -110,6 +112,35 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt return Array(observer.items) } + // MARK: - Message Reminders Properties + + /// The query used for fetching the message reminders. + private var reminderListQuery = MessageReminderListQuery() + + /// Use for observing the current user's message reminders changes. + private var messageRemindersObserver: BackgroundListDatabaseObserver? + + /// The repository for message reminders. + private var reminderRepository: ReminderRepository + + /// The token for the next page of message reminders. + private var messageRemindersNextCursor: String? + + /// A flag to indicate whether all message reminders have been loaded. + public private(set) var hasLoadedAllReminders: Bool = false + + /// The current user's message reminders. + public var messageReminders: [MessageReminder] { + if let observer = messageRemindersObserver { + return Array(observer.items) + } + + let observer = createMessageRemindersObserver(query: reminderListQuery) + return Array(observer.items) + } + + // MARK: - Init + /// Creates a new `CurrentUserControllerGeneric`. /// /// - Parameters: @@ -120,9 +151,10 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt self.client = client self.environment = environment draftMessagesRepository = client.draftMessagesRepository + reminderRepository = client.reminderRepository } - /// Synchronize local data with remote. Waits for the client to connect but doesn’t initiate the connection itself. + /// Synchronize local data with remote. Waits for the client to connect but doesn't initiate the connection itself. /// This is to make sure the fetched local data is up-to-date, since the current user data is updated through WebSocket events. /// /// - Parameter completion: Called when the controller has finished fetching the local data @@ -433,6 +465,68 @@ public extension CurrentChatUserController { } } } + + /// Loads the message reminders for the current user given the provided query. + /// + /// It will load the first page of reminders of the current user. + /// `loadMoreReminders` can be used to load the next pages. + /// + /// - Parameters: + /// - query: The query for filtering the reminders. + /// - completion: Called when the API call is finished. + /// It is optional since it can be observed from the delegate events. + func loadReminders( + query: MessageReminderListQuery = MessageReminderListQuery(), + completion: ((Result<[MessageReminder], Error>) -> Void)? = nil + ) { + reminderListQuery = query + createMessageRemindersObserver(query: query) + reminderRepository.loadReminders(query: query) { result in + self.callback { + switch result { + case let .success(response): + self.messageRemindersNextCursor = response.next + self.hasLoadedAllReminders = response.next == nil + completion?(.success(response.reminders)) + case let .failure(error): + completion?(.failure(error)) + } + } + } + } + + /// Loads more message reminders for the current user. + /// + /// - Parameters: + /// - limit: The number of message reminders to load. If `nil`, the default limit will be used. + /// - completion: Called when the API call is finished. + /// It is optional since it can be observed from the delegate events. + func loadMoreReminders( + limit: Int? = nil, + completion: ((Result<[MessageReminder], Error>) -> Void)? = nil + ) { + guard let nextCursor = messageRemindersNextCursor else { + completion?(.success([])) + return + } + + let limit = limit ?? reminderListQuery.pagination.pageSize + var updatedQuery = reminderListQuery + updatedQuery.pagination = Pagination(pageSize: limit, cursor: nextCursor) + + reminderRepository.loadReminders(query: updatedQuery) { result in + self.callback { + switch result { + case let .success(response): + self.messageRemindersNextCursor = response.next + self.hasLoadedAllReminders = response.next == nil + completion?(.success(response.reminders)) + case let .failure(error): + completion?(.failure(error)) + } + } + } + } } // MARK: - Environment @@ -453,6 +547,14 @@ extension CurrentChatUserController { ) -> BackgroundListDatabaseObserver = { .init(database: $0, fetchRequest: $1, itemCreator: $2, itemReuseKeyPaths: (\DraftMessage.id, \MessageDTO.id)) } + + var messageRemindersObserverBuilder: ( + _ database: DatabaseContainer, + _ fetchRequest: NSFetchRequest, + _ itemCreator: @escaping (MessageReminderDTO) throws -> MessageReminder + ) -> BackgroundListDatabaseObserver = { + .init(database: $0, fetchRequest: $1, itemCreator: $2, itemReuseKeyPaths: (\MessageReminder.id, \MessageReminderDTO.id)) + } var currentUserUpdaterBuilder = CurrentUserUpdater.init } @@ -504,6 +606,24 @@ private extension CurrentChatUserController { draftMessagesObserver = observer return observer } + + @discardableResult + private func createMessageRemindersObserver(query: MessageReminderListQuery) -> BackgroundListDatabaseObserver { + let observer = environment.messageRemindersObserverBuilder( + client.databaseContainer, + MessageReminderDTO.remindersFetchRequest(query: query), + { try $0.asModel() } + ) + observer.onDidChange = { [weak self] _ in + guard let self = self else { return } + self.delegateCallback { + $0.currentUserController(self, didChangeMessageReminders: self.messageReminders) + } + } + try? observer.startObserving() + messageRemindersObserver = observer + return observer + } } // MARK: - Delegates @@ -521,6 +641,12 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { _ controller: CurrentChatUserController, didChangeDraftMessages draftMessages: [DraftMessage] ) + + /// The controller observed a change in the message reminders. + func currentUserController( + _ controller: CurrentChatUserController, + didChangeMessageReminders messageReminders: [MessageReminder] + ) } public extension CurrentChatUserControllerDelegate { @@ -532,6 +658,11 @@ public extension CurrentChatUserControllerDelegate { _ controller: CurrentChatUserController, didChangeDraftMessages draftMessages: [DraftMessage] ) {} + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeMessageReminders messageReminders: [MessageReminder] + ) {} } public extension CurrentChatUserController { diff --git a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift index 52ab4c8f305..f90a276906c 100644 --- a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift @@ -8,14 +8,26 @@ import Foundation @objc(MessageReminderDTO) class MessageReminderDTO: NSManagedObject { @NSManaged var id: String - @NSManaged var remindAt: DBDate? @NSManaged var createdAt: DBDate @NSManaged var updatedAt: DBDate - + @NSManaged var remindAt: DBDate? + + // An helper property that is used for sorting the reminders when `remindAt` is not set. + @NSManaged var sortingRemindAt: DBDate? + // Relationships @NSManaged var message: MessageDTO @NSManaged var channel: ChannelDTO - + + override func willSave() { + super.willSave() + + let newSortingRemindAt = remindAt ?? .distantFuture.bridgeDate + if sortingRemindAt != newSortingRemindAt { + sortingRemindAt = newSortingRemindAt + } + } + /// Returns a fetch request for a message reminder with the provided message ID. static func fetchRequest(messageId: MessageId) -> NSFetchRequest { let request = NSFetchRequest(entityName: MessageReminderDTO.entityName) @@ -24,11 +36,35 @@ class MessageReminderDTO: NSManagedObject { return request } - /// Returns a fetch request for message reminders belonging to the provided channel. - static func fetchRequest(cid: ChannelId) -> NSFetchRequest { + /// Returns a fetch request for message reminders based on the provided query. + static func remindersFetchRequest(query: MessageReminderListQuery) -> NSFetchRequest { let request = NSFetchRequest(entityName: MessageReminderDTO.entityName) - request.predicate = NSPredicate(format: "channel.cid == %@", cid.rawValue) - request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageReminderDTO.remindAt, ascending: true)] + MessageReminderDTO.applyPrefetchingState(to: request) + + // Apply sort descriptors from the query + var sortDescriptors: [NSSortDescriptor] = [] + for sorting in query.sort { + switch sorting.key { + case .remindAt: + sortDescriptors.append(NSSortDescriptor(keyPath: \MessageReminderDTO.sortingRemindAt, ascending: sorting.isAscending)) + case .createdAt: + sortDescriptors.append(NSSortDescriptor(keyPath: \MessageReminderDTO.createdAt, ascending: sorting.isAscending)) + case .updatedAt: + sortDescriptors.append(NSSortDescriptor(keyPath: \MessageReminderDTO.updatedAt, ascending: sorting.isAscending)) + default: + continue + } + } + // Apply default sort if none provided + if sortDescriptors.isEmpty { + sortDescriptors = [NSSortDescriptor(keyPath: \MessageReminderDTO.sortingRemindAt, ascending: true)] + } + request.sortDescriptors = sortDescriptors + + if let filter = query.filter, let predicate = filter.predicate { + request.predicate = predicate + } + return request } diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 1773f022e8a..28ed68db324 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -323,6 +323,7 @@ + diff --git a/Sources/StreamChat/Query/MessageReminderListQuery.swift b/Sources/StreamChat/Query/MessageReminderListQuery.swift index 4fc5392d810..21b4adcd8ac 100644 --- a/Sources/StreamChat/Query/MessageReminderListQuery.swift +++ b/Sources/StreamChat/Query/MessageReminderListQuery.swift @@ -14,19 +14,45 @@ public struct MessageReminderListFilterScope: FilterScope, AnyMessageReminderLis public extension FilterKey where Scope: AnyMessageReminderListFilterScope { /// A filter key for matching the `channel_cid` value. /// Supported operators: `in`, `equal` - static var channelCid: FilterKey { .init(rawValue: "channel_cid", keyPathString: "channelCid", valueMapper: { $0.rawValue }) } - + static var cid: FilterKey { .init( + rawValue: "channel_cid", + keyPathString: #keyPath(MessageReminderDTO.channel.cid), + valueMapper: { $0.rawValue } + ) } + /// A filter key for matching the `message_id` value. /// Supported operators: `in`, `equal` - static var messageId: FilterKey { .init(rawValue: "message_id", keyPathString: "messageId") } - + static var messageId: FilterKey { .init(rawValue: "message_id", keyPathString: #keyPath(MessageReminderDTO.id)) } + /// A filter key for matching the `remind_at` value. /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` - static var remindAt: FilterKey { .init(rawValue: "remind_at", keyPathString: "remindAt") } - + static var remindAt: FilterKey { .init(rawValue: "remind_at", keyPathString: #keyPath(MessageReminderDTO.remindAt)) } + /// A filter key for matching the `created_at` value. /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` - static var createdAt: FilterKey { .init(rawValue: "created_at", keyPathString: "createdAt") } + static var createdAt: FilterKey { .init(rawValue: "created_at", keyPathString: #keyPath(MessageReminderDTO.createdAt)) } +} + +public extension Filter where Scope: AnyMessageReminderListFilterScope { + /// Returns a filter that matches message reminders without a due date. + static var withoutRemindAt: Filter { + .isNil(.remindAt) + } + + /// Returns a filter that matches message reminders with a due date. + static var withRemindAt: Filter { + .exists(.remindAt) + } + + /// Returns a filter that matches message reminders that are overdue. + static var overdue: Filter { + .lessOrEqual(.remindAt, than: Date()) + } + + /// Returns a filter that matches message reminders that are upcoming. + static var upcoming: Filter { + .greaterOrEqual(.remindAt, than: Date()) + } } /// The type describing a value that can be used for sorting when querying message reminders. @@ -56,9 +82,6 @@ public struct MessageReminderListQuery: Encodable { private enum CodingKeys: String, CodingKey { case filter case sort - case limit - case next - case prev } /// A filter for the query (see `Filter`). @@ -67,30 +90,22 @@ public struct MessageReminderListQuery: Encodable { public let sort: [Sorting] /// A pagination. public var pagination: Pagination - /// Next page token for pagination - public var next: String? - /// Previous page token for pagination - public var prev: String? - + /// Init a message reminders query. /// - Parameters: /// - filter: a reminders filter. /// - sort: a sorting list for reminders. /// - pageSize: a page size for pagination. /// - next: a token for fetching the next page. - /// - prev: a token for fetching the previous page. public init( filter: Filter? = nil, sort: [Sorting] = [.init(key: .remindAt, isAscending: true)], - pageSize: Int = 25, - next: String? = nil, - prev: String? = nil + pageSize: Int = 5, + next: String? = nil ) { self.filter = filter self.sort = sort - pagination = Pagination(pageSize: pageSize) - self.next = next - self.prev = prev + pagination = Pagination(pageSize: pageSize, cursor: next) } public func encode(to encoder: Encoder) throws { @@ -104,15 +119,7 @@ public struct MessageReminderListQuery: Encodable { try container.encode(sort, forKey: .sort) } - try container.encode(pagination.pageSize, forKey: .limit) - - if let next = next { - try container.encode(next, forKey: .next) - } - - if let prev = prev { - try container.encode(prev, forKey: .prev) - } + try pagination.encode(to: encoder) } } diff --git a/Sources/StreamChat/Repositories/ReminderRepository.swift b/Sources/StreamChat/Repositories/ReminderRepository.swift new file mode 100644 index 00000000000..1a41d470dc5 --- /dev/null +++ b/Sources/StreamChat/Repositories/ReminderRepository.swift @@ -0,0 +1,59 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData + +/// A response containing a list of reminders and pagination information. +struct ReminderListResponse { + var reminders: [MessageReminder] + var next: String? +} + +/// Repository for handling message reminders. +class ReminderRepository { + /// The database container for local storage operations. + private let database: DatabaseContainer + + /// The API client for remote operations. + private let apiClient: APIClient + + /// Creates a new ReminderRepository instance. + /// - Parameters: + /// - database: The database container for local storage operations. + /// - apiClient: The API client for remote operations. + init(database: DatabaseContainer, apiClient: APIClient) { + self.database = database + self.apiClient = apiClient + } + + /// Loads reminders based on the provided query. + /// - Parameters: + /// - query: The query containing filtering and sorting parameters. + /// - completion: Called when the operation completes. + func loadReminders( + query: MessageReminderListQuery, + completion: @escaping (Result) -> Void + ) { + apiClient.request(endpoint: .queryReminders(query: query)) { [weak self] result in + switch result { + case .success(let response): + var reminders: [MessageReminder] = [] + self?.database.write({ session in + reminders = try response.reminders.compactMap { payload in + let reminderDTO = try session.saveReminder(payload: payload, cache: nil) + return try reminderDTO.asModel() + } + }, completion: { error in + if let error { + completion(.failure(error)) + return + } + completion(.success(ReminderListResponse(reminders: reminders, next: response.next))) + }) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index fe295725caa..e47668ddfa9 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1692,6 +1692,8 @@ ADB8B8ED2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; ADB8B8EE2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; ADB8B8F02D8A493900549C95 /* ReminderPayload.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */; }; + ADB8B8F22D8ADA0700549C95 /* ReminderRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */; }; + ADB8B8F32D8ADA0700549C95 /* ReminderRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */; }; ADB951A1291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951AB291C1DE400800554 /* AttachmentUploader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */; }; @@ -4410,6 +4412,7 @@ ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderDTO.swift; sourceTree = ""; }; ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder.swift; sourceTree = ""; }; ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderPayload.json; sourceTree = ""; }; + ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderRepository.swift; sourceTree = ""; }; ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader_Spy.swift; sourceTree = ""; }; ADB951AC291C22DB00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; @@ -9284,6 +9287,7 @@ C1E8AD59278DDC500041B775 /* Repositories */ = { isa = PBXGroup; children = ( + ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */, C135A1CA28F45F6B0058EFB6 /* AuthenticationRepository.swift */, 88206FC325B18C88009D086A /* ConnectionRepository.swift */, C1B0B38527BFE8AB00C8207D /* MessageRepository.swift */, @@ -11614,6 +11618,7 @@ C1E8AD5E278EF5F30041B775 /* AsyncOperation.swift in Sources */, 88D85DA7252F3C1D00AE1030 /* MemberListController.swift in Sources */, 79280F4B248523C000CDEB89 /* ConnectionEvents.swift in Sources */, + ADB8B8F32D8ADA0700549C95 /* ReminderRepository.swift in Sources */, 79158CF425F133FB00186102 /* ChannelTruncatedEventMiddleware.swift in Sources */, 882C574A252C767E00E60C44 /* ChannelMemberListPayload.swift in Sources */, DAFAD6A324DD8E1A0043ED06 /* ChannelEditDetailPayload.swift in Sources */, @@ -12398,6 +12403,7 @@ C121E852274544AE00023E4C /* ModerationEndpoints.swift in Sources */, C121E853274544AE00023E4C /* WebSocketConnectEndpoint.swift in Sources */, 4F73F39F2B91C7BF00563CD9 /* MessageState+Observer.swift in Sources */, + ADB8B8F22D8ADA0700549C95 /* ReminderRepository.swift in Sources */, C121E854274544AE00023E4C /* MemberEndpoints.swift in Sources */, C121E855274544AE00023E4C /* AttachmentEndpoints.swift in Sources */, C121E856274544AE00023E4C /* ChatRemoteNotificationHandler.swift in Sources */, From 061a821c04ab4c58d6b3342fa58da59413f6ccba Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Mar 2025 18:51:02 +0000 Subject: [PATCH 17/42] Add Reminder List Demo App UI Component --- DemoApp/Screens/DemoAppTabBarController.swift | 21 +- DemoApp/Screens/DemoReminderListVC.swift | 685 ++++++++++++++++++ .../DemoAppCoordinator+DemoApp.swift | 11 + StreamChat.xcodeproj/project.pbxproj | 4 + 4 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 DemoApp/Screens/DemoReminderListVC.swift diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 082f36de7f4..dfd5288fda4 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -10,17 +10,20 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele let channelListVC: UIViewController let threadListVC: UIViewController let draftListVC: UIViewController + let reminderListVC: UIViewController let currentUserController: CurrentChatUserController init( channelListVC: UIViewController, threadListVC: UIViewController, draftListVC: UIViewController, + reminderListVC: UIViewController, currentUserController: CurrentChatUserController ) { self.channelListVC = channelListVC self.threadListVC = threadListVC self.draftListVC = draftListVC + self.reminderListVC = reminderListVC self.currentUserController = currentUserController super.init(nibName: nil, bundle: nil) } @@ -52,6 +55,12 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele currentUserController.delegate = self unreadCount = currentUserController.unreadCount + // Load reminders with remindAt to update the badge. + currentUserController.loadReminders(query: .init( + filter: .withRemindAt, + pageSize: 50 + )) + tabBar.backgroundColor = Appearance.default.colorPalette.background tabBar.isTranslucent = true @@ -65,8 +74,11 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele draftListVC.tabBarItem.title = "Drafts" draftListVC.tabBarItem.image = UIImage(systemName: "bubble.and.pencil") + + reminderListVC.tabBarItem.title = "Reminders" + reminderListVC.tabBarItem.image = UIImage(systemName: "bell") - viewControllers = [channelListVC, threadListVC, draftListVC] + viewControllers = [channelListVC, threadListVC, draftListVC, reminderListVC] } func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { @@ -75,4 +87,11 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele let totalUnreadBadge = unreadCount.channels + unreadCount.threads UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge } + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeMessageReminders messageReminders: [MessageReminder] + ) { + reminderListVC.tabBarItem.badgeValue = messageReminders.isEmpty ? nil : "\(messageReminders.count)" + } } diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift new file mode 100644 index 00000000000..c04fa87acf3 --- /dev/null +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -0,0 +1,685 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +import StreamChatUI +import UIKit + +class DemoReminderListVC: UIViewController, ThemeProvider { + var onLogout: (() -> Void)? + var onDisconnect: (() -> Void)? + + private let currentUserController: CurrentChatUserController + private var reminders: [MessageReminder] = [] + private var isPaginatingReminders = false + + // Timer for refreshing due dates on cells + private var refreshTimer: Timer? + + // Filter options + enum FilterOption: Int, CaseIterable { + case all, overdue, upcoming, scheduled, later + + var title: String { + switch self { + case .all: return "All" + case .scheduled: return "Scheduled" + case .overdue: return "Overdue" + case .upcoming: return "Upcoming" + case .later: return "Saved for later" + } + } + } + + private var selectedFilter: FilterOption = .all { + didSet { + if oldValue != selectedFilter { + loadReminders() + updateFilterPills() + } + } + } + + lazy var userAvatarView: CurrentChatUserAvatarView = components + .currentUserAvatarView.init() + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.dataSource = self + tableView.register(DemoReminderCell.self, forCellReuseIdentifier: "DemoReminderCell") + return tableView + }() + + private lazy var filtersScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.contentInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + return scrollView + }() + + private lazy var filtersStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + return stackView + }() + + private lazy var loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.translatesAutoresizingMaskIntoConstraints = false + return indicator + }() + + private var emptyStateLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.textColor = Appearance.default.colorPalette.subtitleText + label.font = Appearance.default.fonts.body + return label + }() + + private lazy var emptyStateImageView: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "bell.slash")) + imageView.contentMode = .scaleAspectFit + imageView.tintColor = Appearance.default.colorPalette.subtitleText + return imageView + }() + + private lazy var emptyStateView: UIView = { + VContainer(spacing: 12, alignment: .center) { + emptyStateImageView + .width(48) + .height(48) + emptyStateLabel + } + }() + + init(currentUserController: CurrentChatUserController) { + self.currentUserController = currentUserController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Reminders" + + userAvatarView.controller = currentUserController + userAvatarView.addTarget(self, action: #selector(didTapOnCurrentUserAvatar), for: .touchUpInside) + userAvatarView.translatesAutoresizingMaskIntoConstraints = false + + navigationItem.backButtonTitle = "" + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: userAvatarView) + + setupViews() + setupFilterPills() + updateEmptyStateMessage() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + loadReminders() + startRefreshTimer() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopRefreshTimer() + } + + private func startRefreshTimer() { + // Cancel any existing timer first + stopRefreshTimer() + + // Create a new timer that fires every 60 seconds + refreshTimer = Timer.scheduledTimer( + timeInterval: 60.0, + target: self, + selector: #selector(refreshVisibleCells), + userInfo: nil, + repeats: true + ) + + // Add to RunLoop to ensure it works while scrolling + if let timer = refreshTimer { + RunLoop.current.add(timer, forMode: .common) + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + @objc private func refreshVisibleCells() { + // Only refresh visible cells to avoid unnecessary work + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { return } + + for indexPath in visibleIndexPaths { + if indexPath.row < reminders.count, + let cell = tableView.cellForRow(at: indexPath) as? DemoReminderCell { + let reminder = reminders[indexPath.row] + cell.configure(with: reminder) + } + } + } + + private func createFilterQuery() -> MessageReminderListQuery { + switch selectedFilter { + case .all: + return MessageReminderListQuery() + + case .scheduled: + return MessageReminderListQuery( + filter: .withRemindAt, + sort: [.init(key: .remindAt, isAscending: true)] + ) + + case .later: + return MessageReminderListQuery( + filter: .withoutRemindAt, + sort: [.init(key: .createdAt, isAscending: false)] + ) + + case .overdue: + return MessageReminderListQuery( + filter: .overdue, + sort: [.init(key: .remindAt, isAscending: false)] + ) + + case .upcoming: + return MessageReminderListQuery( + filter: .upcoming, + sort: [.init(key: .remindAt, isAscending: true)] + ) + } + } + + private func setupViews() { + view.backgroundColor = Appearance.default.colorPalette.background + tableView.backgroundColor = Appearance.default.colorPalette.background + + let headerView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 50)) + headerView.backgroundColor = Appearance.default.colorPalette.background + headerView.addSubview(filtersScrollView) + filtersScrollView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + filtersScrollView.topAnchor.constraint(equalTo: headerView.topAnchor), + filtersScrollView.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), + filtersScrollView.trailingAnchor.constraint(equalTo: headerView.trailingAnchor), + filtersScrollView.bottomAnchor.constraint(equalTo: headerView.bottomAnchor) + ]) + + filtersScrollView.addSubview(filtersStackView) + NSLayoutConstraint.activate([ + filtersStackView.topAnchor.constraint(equalTo: filtersScrollView.topAnchor), + filtersStackView.leadingAnchor.constraint(equalTo: filtersScrollView.leadingAnchor), + filtersStackView.trailingAnchor.constraint(equalTo: filtersScrollView.trailingAnchor), + filtersStackView.bottomAnchor.constraint(equalTo: filtersScrollView.bottomAnchor), + filtersStackView.heightAnchor.constraint(equalTo: filtersScrollView.heightAnchor) + ]) + + view.addSubview(tableView) + tableView.tableHeaderView = headerView + tableView.addSubview(emptyStateView) + tableView.addSubview(loadingIndicator) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + emptyStateView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), + emptyStateView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor), + emptyStateView.widthAnchor.constraint(equalTo: tableView.widthAnchor), + emptyStateView.heightAnchor.constraint(equalToConstant: 100), + + loadingIndicator.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: tableView.centerYAnchor) + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Update header view width when view size changes + if let headerView = tableView.tableHeaderView { + let width = tableView.bounds.width + var frame = headerView.frame + + // Only update if width changed + if frame.width != width { + frame.size.width = width + headerView.frame = frame + tableView.tableHeaderView = headerView + } + } + } + + private func setupFilterPills() { + for filterOption in FilterOption.allCases { + let pillButton = createFilterPillButton(for: filterOption) + filtersStackView.addArrangedSubview(pillButton) + } + updateFilterPills() + } + + private func createFilterPillButton(for filterOption: FilterOption) -> UIButton { + let button = UIButton(type: .system) + button.tag = filterOption.rawValue + button.setTitle(filterOption.title, for: .normal) + button.titleLabel?.font = Appearance.default.fonts.footnote + button.layer.cornerRadius = 12 + button.layer.masksToBounds = true + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) + button.addTarget(self, action: #selector(didTapFilterPill), for: .touchUpInside) + return button + } + + private func updateFilterPills() { + for subview in filtersStackView.arrangedSubviews { + guard let button = subview as? UIButton else { continue } + + if button.tag == selectedFilter.rawValue { + button.backgroundColor = Appearance.default.colorPalette.accentPrimary + button.setTitleColor(.white, for: .normal) + } else { + button.backgroundColor = Appearance.default.colorPalette.background2 + button.setTitleColor(Appearance.default.colorPalette.text, for: .normal) + } + } + + // Update empty state message when filter changes + updateEmptyStateMessage() + } + + private func updateEmptyStateMessage() { + switch selectedFilter { + case .all: + emptyStateLabel.text = "No reminders" + emptyStateImageView.image = UIImage(systemName: "bell.slash") + case .scheduled: + emptyStateLabel.text = "No scheduled reminders" + emptyStateImageView.image = UIImage(systemName: "bell.slash") + case .later: + emptyStateLabel.text = "No saved for later" + emptyStateImageView.image = UIImage(systemName: "bookmark.slash") + case .overdue: + emptyStateLabel.text = "No overdue reminders" + emptyStateImageView.image = UIImage(systemName: "bell.slash") + case .upcoming: + emptyStateLabel.text = "No upcoming reminders" + emptyStateImageView.image = UIImage(systemName: "bell.slash") + } + } + + @objc private func didTapFilterPill(_ sender: UIButton) { + guard let filterOption = FilterOption(rawValue: sender.tag) else { return } + selectedFilter = filterOption + } + + private func loadReminders() { + currentUserController.delegate = self + if reminders.isEmpty { + loadingIndicator.startAnimating() + } + + let query = createFilterQuery() + currentUserController.loadReminders(query: query) { [weak self] _ in + self?.loadingIndicator.stopAnimating() + } + } + + private func loadMoreReminders() { + guard !isPaginatingReminders && !currentUserController.hasLoadedAllReminders else { + return + } + + isPaginatingReminders = true + currentUserController.loadMoreReminders { [weak self] _ in + self?.isPaginatingReminders = false + } + } + + @objc private func didTapOnCurrentUserAvatar(_ sender: Any) { + presentUserOptionsAlert( + onLogout: onLogout, + onDisconnect: onDisconnect, + client: currentUserController.client + ) + } + + private func showEditReminderOptions(for reminder: MessageReminder, at indexPath: IndexPath) { + let alert = UIAlertController(title: "Edit Reminder", message: nil, preferredStyle: .actionSheet) + + alert.addAction(UIAlertAction(title: "Remind in 2 Minutes", style: .default) { [weak self] _ in + let date = Date().addingTimeInterval(2 * 60) + self?.updateReminderDate(for: reminder, newDate: date) + }) + + alert.addAction(UIAlertAction(title: "Remind in 1 Hour", style: .default) { [weak self] _ in + let date = Date().addingTimeInterval(60 * 60) + self?.updateReminderDate(for: reminder, newDate: date) + }) + + alert.addAction(UIAlertAction(title: "Remind tomorrow", style: .default) { [weak self] _ in + let date = Date().addingTimeInterval(24 * 60 * 60) + self?.updateReminderDate(for: reminder, newDate: date) + }) + + alert.addAction(UIAlertAction(title: "Clear due date", style: .default) { [weak self] _ in + self?.updateReminderDate(for: reminder, newDate: nil) + }) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popoverController = alert.popoverPresentationController { + if let cell = tableView.cellForRow(at: indexPath) { + popoverController.sourceView = cell + popoverController.sourceRect = cell.bounds + } + } + + present(alert, animated: true) + } + + private func updateReminderDate(for reminder: MessageReminder, newDate: Date?) { + let messageController = currentUserController.client.messageController( + cid: reminder.channel.cid, + messageId: reminder.message.id + ) + + messageController.updateReminder(remindAt: newDate) + } + + private func showErrorAlert(message: String) { + let alert = UIAlertController( + title: "Error", + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + +// MARK: - CurrentChatUserControllerDelegate + +extension DemoReminderListVC: CurrentChatUserControllerDelegate { + func currentUserController( + _ controller: CurrentChatUserController, + didChangeMessageReminders messageReminders: [MessageReminder] + ) { + reminders = messageReminders + tableView.reloadData() + updateEmptyStateMessage() + emptyStateView.isHidden = !reminders.isEmpty + } +} + +// MARK: - UITableViewDataSource & UITableViewDelegate + +extension DemoReminderListVC: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + reminders.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "DemoReminderCell", for: indexPath) as? DemoReminderCell + let reminder = reminders[indexPath.row] + cell?.configure(with: reminder) + return cell ?? .init() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Only react to table view scrolling, not the filter scroll view + guard scrollView == tableView else { return } + + let threshold: CGFloat = 100 + let contentOffset = scrollView.contentOffset.y + let maximumOffset = scrollView.contentSize.height - scrollView.frame.size.height + + if maximumOffset - contentOffset <= threshold { + loadMoreReminders() + } + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let reminder = reminders[indexPath.row] + + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in + guard let self = self else { return } + + let messageController = self.currentUserController.client.messageController( + cid: reminder.channel.cid, + messageId: reminder.message.id + ) + + messageController.deleteReminder { error in + if let error = error { + self.showErrorAlert(message: "Failed to delete reminder: \(error.localizedDescription)") + completion(false) + } else { + completion(true) + } + } + } + + let editAction = UIContextualAction(style: .normal, title: "Edit") { [weak self] _, _, completion in + guard let self = self else { return } + self.showEditReminderOptions(for: reminder, at: indexPath) + completion(true) + } + editAction.backgroundColor = .systemBlue + + return UISwipeActionsConfiguration(actions: [deleteAction, editAction]) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let reminder = reminders[indexPath.row] + let channelController = currentUserController.client.channelController( + for: ChannelQuery( + cid: reminder.channel.cid, + paginationParameter: .around(reminder.message.id) + ) + ) + + let channelVC = DemoChatChannelVC() + channelVC.channelController = channelController + navigationController?.pushViewController(channelVC, animated: true) + } +} + +// MARK: - Reminder Cell + +class DemoReminderCell: UITableViewCell { + private let channelNameLabel: UILabel = { + let label = UILabel() + label.font = Appearance.default.fonts.bodyBold + label.textColor = Appearance.default.colorPalette.text + return label + }() + + private let messageLabel: UILabel = { + let label = UILabel() + label.font = Appearance.default.fonts.footnote + label.numberOfLines = 2 + label.textColor = Appearance.default.colorPalette.subtitleText + return label + }() + + private let dueDateContainer: UIView = { + let view = UIView() + view.layer.cornerRadius = 12 + view.layer.masksToBounds = true + return view + }() + + private let dueDateLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = Appearance.default.fonts.footnoteBold + label.textColor = Appearance.default.colorPalette.staticColorText + label.textAlignment = .center + return label + }() + + private let saveForLaterIconView: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "bookmark.fill")) + imageView.contentMode = .scaleAspectFit + imageView.tintColor = Appearance.default.colorPalette.accentPrimary + return imageView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = Appearance.default.colorPalette.background + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + dueDateContainer.addSubview(dueDateLabel) + dueDateContainer.setContentCompressionResistancePriority(.required, for: .horizontal) + dueDateLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + messageLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + NSLayoutConstraint.activate([ + dueDateLabel.topAnchor.constraint(equalTo: dueDateContainer.topAnchor, constant: 4), + dueDateLabel.leadingAnchor.constraint(equalTo: dueDateContainer.leadingAnchor, constant: 8), + dueDateLabel.trailingAnchor.constraint(equalTo: dueDateContainer.trailingAnchor, constant: -8), + dueDateLabel.bottomAnchor.constraint(equalTo: dueDateContainer.bottomAnchor, constant: -4) + ]) + + VContainer(spacing: 8) { + HContainer(spacing: 4) { + channelNameLabel + Spacer() + saveForLaterIconView + dueDateContainer + } + messageLabel + .height(20) + }.embed( + in: contentView, + insets: .init(top: 8, leading: 16, bottom: 8, trailing: 16) + ) + } + + func configure(with reminder: MessageReminder) { + let channelName = Appearance.default.formatters.channelName.format( + channel: reminder.channel, + forCurrentUserId: StreamChatWrapper.shared.client?.currentUserId + ) ?? "" + + if reminder.message.parentMessageId != nil { + channelNameLabel.text = "Thread in # \(channelName)" + } else { + channelNameLabel.text = "# \(channelName)" + } + + messageLabel.text = reminder.message.text + + // Configure based on reminder type + if let remindAt = reminder.remindAt { + // Check if reminder is overdue + let now = Date() + if remindAt < now { + let timeInterval = now.timeIntervalSince(remindAt) + dueDateLabel.text = formatOverdueTime(timeInterval: timeInterval) + dueDateContainer.backgroundColor = Appearance.default.colorPalette.alert + } else { + let timeInterval = remindAt.timeIntervalSince(now) + dueDateLabel.text = "Due in \(formatDueTime(timeInterval: timeInterval))" + dueDateContainer.backgroundColor = Appearance.default.colorPalette.accentPrimary + } + dueDateContainer.isHidden = false + saveForLaterIconView.isHidden = true + } else { + saveForLaterIconView.isHidden = false + dueDateContainer.isHidden = true + } + } + + private func formatOverdueTime(timeInterval: TimeInterval) -> String { + // Round to the nearest minute (30 seconds or more rounds up) + let roundedMinutes = ceil(timeInterval / 60 - 0.5) + let roundedInterval = roundedMinutes * 60 + + // If less than a minute, show "1 min" instead of "0 min" + if roundedInterval == 0 { + return "Overdue by 1 min" + } + + let formatter = DateComponentsFormatter() + + if roundedInterval < 3600 { + // For durations less than an hour, show only minutes + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 1 + } else { + // For longer durations, show days and hours, or hours and minutes + formatter.allowedUnits = [.day, .hour, .minute] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + } + + guard let formattedString = formatter.string(from: roundedInterval) else { + return "Overdue" + } + + return "Overdue by \(formattedString)" + } + + private func formatDueTime(timeInterval: TimeInterval) -> String { + // Round to the nearest minute (30 seconds or more rounds up) + let roundedMinutes = ceil(timeInterval / 60 - 0.5) + let roundedInterval = roundedMinutes * 60 + + // If less than a minute, show "1 min" instead of "0 min" + if roundedInterval == 0 { + return "1 min" + } + + let formatter = DateComponentsFormatter() + + if roundedInterval < 3600 { + // For durations less than an hour, show only minutes + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 1 + } else { + // For longer durations, show days and hours, or hours and minutes + formatter.allowedUnits = [.day, .hour, .minute] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + } + + guard let formattedString = formatter.string(from: roundedInterval) else { + return "soon" + } + + return formattedString + } +} diff --git a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift index e63244d3ff0..07975e9fe65 100644 --- a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift +++ b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift @@ -57,11 +57,22 @@ extension DemoAppCoordinator { draftsVC.onDisconnect = { [weak self] in self?.disconnect() } + + let reminderListVC = DemoReminderListVC( + currentUserController: client.currentUserController() + ) + reminderListVC.onLogout = { [weak self] in + self?.logOut() + } + reminderListVC.onDisconnect = { [weak self] in + self?.disconnect() + } let tabBarViewController = DemoAppTabBarController( channelListVC: chatVC, threadListVC: UINavigationController(rootViewController: threadListVC), draftListVC: UINavigationController(rootViewController: draftsVC), + reminderListVC: UINavigationController(rootViewController: reminderListVC), currentUserController: client.currentUserController() ) set(rootViewController: tabBarViewController, animated: animated) diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index e47668ddfa9..f712a251521 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1694,6 +1694,7 @@ ADB8B8F02D8A493900549C95 /* ReminderPayload.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */; }; ADB8B8F22D8ADA0700549C95 /* ReminderRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */; }; ADB8B8F32D8ADA0700549C95 /* ReminderRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */; }; + ADB8B8F52D8ADC9400549C95 /* DemoReminderListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */; }; ADB951A1291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951AB291C1DE400800554 /* AttachmentUploader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */; }; @@ -4413,6 +4414,7 @@ ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder.swift; sourceTree = ""; }; ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderPayload.json; sourceTree = ""; }; ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderRepository.swift; sourceTree = ""; }; + ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoReminderListVC.swift; sourceTree = ""; }; ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader_Spy.swift; sourceTree = ""; }; ADB951AC291C22DB00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; @@ -6800,6 +6802,7 @@ A3227ECA284A607D00EBE6CC /* Screens */ = { isa = PBXGroup; children = ( + ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */, AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */, ADD328592C04DD8300BAD0E9 /* DemoAppTabBarController.swift */, C10B5C712A1F794A006A5BCB /* MembersViewController.swift */, @@ -11123,6 +11126,7 @@ A3227E59284A484300EBE6CC /* UIImage+Resized.swift in Sources */, 79B8B64B285CBDC00059FB2D /* DemoChatMessageLayoutOptionsResolver.swift in Sources */, AD053BA32B335A13003612B6 /* LocationAttachmentPayload+AttachmentViewProvider.swift in Sources */, + ADB8B8F52D8ADC9400549C95 /* DemoReminderListVC.swift in Sources */, AD053B9D2B3358E2003612B6 /* LocationAttachmentPayload.swift in Sources */, AD053BA12B3359DD003612B6 /* DemoAttachmentViewCatalog.swift in Sources */, AD053B9F2B335929003612B6 /* LocationAttachmentViewInjector.swift in Sources */, From 795da15ce3d32c0408533dc8f3790f5fff1b5ed8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 19 Mar 2025 23:16:18 +0000 Subject: [PATCH 18/42] Move reminder MessageUpdater functions to RemindersRepository --- .../StreamChat/ChatClient+Environment.swift | 6 +- Sources/StreamChat/ChatClient.swift | 4 +- .../CurrentUserController.swift | 10 +- .../MessageController/MessageController.swift | 10 +- .../Repositories/ReminderRepository.swift | 59 -- .../Repositories/RemindersRepository.swift | 250 ++++++++ .../StreamChat/Workers/MessageUpdater.swift | 193 ------ StreamChat.xcodeproj/project.pbxproj | 28 +- .../Mocks/StreamChat/ChatClient_Mock.swift | 6 + .../RemindersRepository_Mock.swift | 121 ++++ .../Workers/MessageUpdater_Mock.swift | 74 --- .../Endpoints/MessageEndpoints_Tests.swift | 2 +- .../Payloads/MessagePayloads_Tests.swift | 2 - ...urrentUserController+Reminders_Tests.swift | 322 ++++++++++ .../MessageController+Reminders_Tests.swift | 207 +++++++ .../MessageController_Tests.swift | 171 ------ .../MessageReminderListQuery_Tests.swift | 30 +- .../RemindersRepository_Tests.swift | 562 ++++++++++++++++++ .../Workers/MessageUpdater_Tests.swift | 465 --------------- 19 files changed, 1516 insertions(+), 1006 deletions(-) delete mode 100644 Sources/StreamChat/Repositories/ReminderRepository.swift create mode 100644 Sources/StreamChat/Repositories/RemindersRepository.swift create mode 100644 TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift create mode 100644 Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift create mode 100644 Tests/StreamChatTests/Controllers/MessageController/MessageController+Reminders_Tests.swift create mode 100644 Tests/StreamChatTests/Repositories/RemindersRepository_Tests.swift diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index fa8908cde5d..bc27e8b5974 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -146,11 +146,11 @@ extension ChatClient { DraftMessagesRepository(database: $0, apiClient: $1) } - var reminderRepositoryBuilder: ( + var remindersRepositoryBuilder: ( _ database: DatabaseContainer, _ apiClient: APIClient - ) -> ReminderRepository = { - ReminderRepository(database: $0, apiClient: $1) + ) -> RemindersRepository = { + RemindersRepository(database: $0, apiClient: $1) } var channelListUpdaterBuilder: ( diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 6651658d1ba..6c269a8e2c1 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -84,8 +84,8 @@ public class ChatClient { }() /// Repository for handling message reminders - lazy var reminderRepository: ReminderRepository = { - environment.reminderRepositoryBuilder(databaseContainer, apiClient) + lazy var remindersRepository: RemindersRepository = { + environment.remindersRepositoryBuilder(databaseContainer, apiClient) }() let channelListUpdater: ChannelListUpdater diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index e8b375d7d70..861d8e943c8 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -121,8 +121,8 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt private var messageRemindersObserver: BackgroundListDatabaseObserver? /// The repository for message reminders. - private var reminderRepository: ReminderRepository - + private var remindersRepository: RemindersRepository + /// The token for the next page of message reminders. private var messageRemindersNextCursor: String? @@ -151,7 +151,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt self.client = client self.environment = environment draftMessagesRepository = client.draftMessagesRepository - reminderRepository = client.reminderRepository + remindersRepository = client.remindersRepository } /// Synchronize local data with remote. Waits for the client to connect but doesn't initiate the connection itself. @@ -481,7 +481,7 @@ public extension CurrentChatUserController { ) { reminderListQuery = query createMessageRemindersObserver(query: query) - reminderRepository.loadReminders(query: query) { result in + remindersRepository.loadReminders(query: query) { result in self.callback { switch result { case let .success(response): @@ -514,7 +514,7 @@ public extension CurrentChatUserController { var updatedQuery = reminderListQuery updatedQuery.pagination = Pagination(pageSize: limit, cursor: nextCursor) - reminderRepository.loadReminders(query: updatedQuery) { result in + remindersRepository.loadReminders(query: updatedQuery) { result in self.callback { switch result { case let .success(response): diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index bc6d3423e97..4caf77b7174 100644 --- a/Sources/StreamChat/Controllers/MessageController/MessageController.swift +++ b/Sources/StreamChat/Controllers/MessageController/MessageController.swift @@ -190,6 +190,9 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP /// The drafts repository. private let draftsRepository: DraftMessagesRepository + /// The reminders repository. + private let remindersRepository: RemindersRepository + /// Creates a new `MessageControllerGeneric`. /// - Parameters: /// - client: The `Client` instance this controller belongs to. @@ -210,6 +213,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP client.apiClient ) draftsRepository = client.draftMessagesRepository + remindersRepository = client.remindersRepository super.init() setRepliesObserver() @@ -942,7 +946,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP remindAt: Date? = nil, completion: ((Result) -> Void)? = nil ) { - messageUpdater.createReminder( + remindersRepository.createReminder( messageId: messageId, cid: cid, remindAt: remindAt @@ -962,7 +966,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP remindAt: Date?, completion: ((Result) -> Void)? = nil ) { - messageUpdater.updateReminder( + remindersRepository.updateReminder( messageId: messageId, cid: cid, remindAt: remindAt @@ -979,7 +983,7 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP public func deleteReminder( completion: ((Error?) -> Void)? = nil ) { - messageUpdater.deleteReminder( + remindersRepository.deleteReminder( messageId: messageId, cid: cid ) { error in diff --git a/Sources/StreamChat/Repositories/ReminderRepository.swift b/Sources/StreamChat/Repositories/ReminderRepository.swift deleted file mode 100644 index 1a41d470dc5..00000000000 --- a/Sources/StreamChat/Repositories/ReminderRepository.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import CoreData - -/// A response containing a list of reminders and pagination information. -struct ReminderListResponse { - var reminders: [MessageReminder] - var next: String? -} - -/// Repository for handling message reminders. -class ReminderRepository { - /// The database container for local storage operations. - private let database: DatabaseContainer - - /// The API client for remote operations. - private let apiClient: APIClient - - /// Creates a new ReminderRepository instance. - /// - Parameters: - /// - database: The database container for local storage operations. - /// - apiClient: The API client for remote operations. - init(database: DatabaseContainer, apiClient: APIClient) { - self.database = database - self.apiClient = apiClient - } - - /// Loads reminders based on the provided query. - /// - Parameters: - /// - query: The query containing filtering and sorting parameters. - /// - completion: Called when the operation completes. - func loadReminders( - query: MessageReminderListQuery, - completion: @escaping (Result) -> Void - ) { - apiClient.request(endpoint: .queryReminders(query: query)) { [weak self] result in - switch result { - case .success(let response): - var reminders: [MessageReminder] = [] - self?.database.write({ session in - reminders = try response.reminders.compactMap { payload in - let reminderDTO = try session.saveReminder(payload: payload, cache: nil) - return try reminderDTO.asModel() - } - }, completion: { error in - if let error { - completion(.failure(error)) - return - } - completion(.success(ReminderListResponse(reminders: reminders, next: response.next))) - }) - case .failure(let error): - completion(.failure(error)) - } - } - } -} diff --git a/Sources/StreamChat/Repositories/RemindersRepository.swift b/Sources/StreamChat/Repositories/RemindersRepository.swift new file mode 100644 index 00000000000..e38bf753576 --- /dev/null +++ b/Sources/StreamChat/Repositories/RemindersRepository.swift @@ -0,0 +1,250 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData + +/// A response containing a list of reminders and pagination information. +struct ReminderListResponse { + var reminders: [MessageReminder] + var next: String? +} + +/// Repository for handling message reminders. +class RemindersRepository { + /// The database container for local storage operations. + private let database: DatabaseContainer + + /// The API client for remote operations. + private let apiClient: APIClient + + /// Creates a new RemindersRepository instance. + /// - Parameters: + /// - database: The database container for local storage operations. + /// - apiClient: The API client for remote operations. + init(database: DatabaseContainer, apiClient: APIClient) { + self.database = database + self.apiClient = apiClient + } + + /// Loads reminders based on the provided query. + /// - Parameters: + /// - query: The query containing filtering and sorting parameters. + /// - completion: Called when the operation completes. + func loadReminders( + query: MessageReminderListQuery, + completion: @escaping (Result) -> Void + ) { + apiClient.request(endpoint: .queryReminders(query: query)) { [weak self] result in + switch result { + case .success(let response): + var reminders: [MessageReminder] = [] + self?.database.write({ session in + reminders = try response.reminders.compactMap { payload in + let reminderDTO = try session.saveReminder(payload: payload, cache: nil) + return try reminderDTO.asModel() + } + }, completion: { error in + if let error { + completion(.failure(error)) + return + } + completion(.success(ReminderListResponse(reminders: reminders, next: response.next))) + }) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Creates a new reminder for a message. + /// - Parameters: + /// - messageId: The message identifier to create a reminder for. + /// - cid: The channel identifier the message belongs to. + /// - remindAt: The date when the user should be reminded about this message. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + func createReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + let requestBody = ReminderRequestBody(remindAt: remindAt) + let endpoint: Endpoint = .createReminder( + messageId: messageId, + request: requestBody + ) + + // First optimistically create the reminder locally + database.write { session in + let now = Date() + let reminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + message: nil, + remindAt: remindAt, + createdAt: now, + updatedAt: now + ) + + do { + try session.saveReminder(payload: reminderPayload, cache: nil) + } catch { + log.warning("Failed to optimistically create reminder in the database: \(error)") + } + } completion: { _ in + // Make the API call to create the reminder + self.apiClient.request(endpoint: endpoint) { result in + switch result { + case .success(let payload): + var reminder: MessageReminder! + self.database.write({ session in + let messageReminder = payload.reminder + reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() + }, completion: { error in + if let error { + completion(.failure(error)) + } else { + completion(.success(reminder)) + } + }) + case .failure(let error): + // Rollback the optimistic update if the API call fails + self.database.write({ session in + session.deleteReminder(messageId: messageId) + }, completion: { _ in + completion(.failure(error)) + }) + } + } + } + } + + /// Updates an existing reminder for a message. + /// - Parameters: + /// - messageId: The message identifier for the reminder to update. + /// - cid: The channel identifier the message belongs to. + /// - remindAt: The new date when the user should be reminded about this message. + /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. + func updateReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + let requestBody = ReminderRequestBody(remindAt: remindAt) + let endpoint: Endpoint = .updateReminder(messageId: messageId, request: requestBody) + + // Save current data for potential rollback + var originalRemindAt: Date? + + // First optimistically update the reminder locally + database.write { session in + // Verify the message exists + guard let messageDTO = session.message(id: messageId) else { + log.warning("Failed to find message with id: \(messageId) for updating reminder") + return + } + + originalRemindAt = messageDTO.reminder?.remindAt?.bridgeDate + + messageDTO.reminder?.remindAt = remindAt?.bridgeDate + } completion: { [weak self] _ in + // Make the API call to update the reminder + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case .success(let payload): + var reminder: MessageReminder! + self?.database.write({ session in + let messageReminder = payload.reminder + reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() + }, completion: { error in + if let error { + completion(.failure(error)) + } else { + completion(.success(reminder)) + } + }) + + case .failure(let error): + self?.database.write({ session in + // Restore original value + guard let messageDTO = session.message(id: messageId) else { + return + } + messageDTO.reminder?.remindAt = originalRemindAt?.bridgeDate + }, completion: { _ in + completion(.failure(error)) + }) + } + } + } + } + + /// Deletes a reminder for a message. + /// - Parameters: + /// - messageId: The message identifier for the reminder to delete. + /// - cid: The channel identifier the message belongs to. + /// - completion: Called when the API call is finished. Called with an error if the remote update fails. + func deleteReminder( + messageId: MessageId, + cid: ChannelId, + completion: @escaping ((Error?) -> Void) + ) { + let endpoint: Endpoint = .deleteReminder(messageId: messageId) + + // Save data for potential rollback + var originalPayload: ReminderPayload? + + // First optimistically delete the reminder locally + database.write { session in + // Verify the message exists + guard let messageDTO = session.message(id: messageId) else { + log.warning("Failed to find message with id: \(messageId) for deleting reminder") + return + } + + // Get original reminder data for potential rollback + if let reminderDTO = messageDTO.reminder { + // Store the original state for potential rollback + originalPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + message: nil, + remindAt: reminderDTO.remindAt?.bridgeDate, + createdAt: reminderDTO.createdAt.bridgeDate, + updatedAt: reminderDTO.updatedAt.bridgeDate + ) + } + + // Delete optimistically + session.deleteReminder(messageId: messageId) + } completion: { [weak self] _ in + // Make the API call to delete the reminder + self?.apiClient.request(endpoint: endpoint) { result in + switch result { + case .success: + completion(nil) + + case .failure(let error): + // Rollback the optimistic delete if the API call fails + guard let originalPayload = originalPayload else { + completion(error) + return + } + + self?.database.write({ session in + // Restore original reminder + do { + try session.saveReminder(payload: originalPayload, cache: nil) + } catch { + log.warning("Failed to rollback reminder deletion: \(error)") + } + }, completion: { _ in + completion(error) + }) + } + } + } + } +} diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 538ce3e90f1..cc38c233cd0 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -909,199 +909,6 @@ class MessageUpdater: Worker { } } } - - // MARK: - Reminder Actions - - /// Creates a new reminder for a message. - /// - Parameters: - /// - messageId: The message identifier to create a reminder for. - /// - cid: The channel identifier the message belongs to. - /// - remindAt: The date when the user should be reminded about this message. - /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. - func createReminder( - messageId: MessageId, - cid: ChannelId, - remindAt: Date?, - completion: @escaping ((Result) -> Void) - ) { - let requestBody = ReminderRequestBody(remindAt: remindAt) - let endpoint: Endpoint = .createReminder( - messageId: messageId, - request: requestBody - ) - - // First optimistically create the reminder locally - database.write { session in - let now = Date() - let reminderPayload = ReminderPayload( - channelCid: cid, - messageId: messageId, - message: nil, - remindAt: remindAt, - createdAt: now, - updatedAt: now - ) - - do { - try session.saveReminder(payload: reminderPayload, cache: nil) - } catch { - log.warning("Failed to optimistically create reminder in the database: \(error)") - } - } completion: { _ in - // Make the API call to create the reminder - self.apiClient.request(endpoint: endpoint) { result in - switch result { - case .success(let payload): - var reminder: MessageReminder! - self.database.write({ session in - let messageReminder = payload.reminder - reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() - }, completion: { error in - if let error { - completion(.failure(error)) - } else { - completion(.success(reminder)) - } - }) - case .failure(let error): - // Rollback the optimistic update if the API call fails - self.database.write({ session in - session.deleteReminder(messageId: messageId) - }, completion: { _ in - completion(.failure(error)) - }) - } - } - } - } - - /// Updates an existing reminder for a message. - /// - Parameters: - /// - messageId: The message identifier for the reminder to update. - /// - cid: The channel identifier the message belongs to. - /// - remindAt: The new date when the user should be reminded about this message. - /// - completion: Called when the API call is finished. Called with `Error` if the remote update fails. - func updateReminder( - messageId: MessageId, - cid: ChannelId, - remindAt: Date?, - completion: @escaping ((Result) -> Void) - ) { - let requestBody = ReminderRequestBody(remindAt: remindAt) - let endpoint: Endpoint = .updateReminder(messageId: messageId, request: requestBody) - - // Save current data for potential rollback - var originalRemindAt: Date? - - // First optimistically update the reminder locally - database.write { session in - // Verify the message exists - guard let messageDTO = session.message(id: messageId) else { - log.warning("Failed to find message with id: \(messageId) for updating reminder") - return - } - - originalRemindAt = messageDTO.reminder?.remindAt?.bridgeDate - - messageDTO.reminder?.remindAt = remindAt?.bridgeDate - } completion: { [weak self] _ in - // Make the API call to update the reminder - self?.apiClient.request(endpoint: endpoint) { result in - switch result { - case .success(let payload): - var reminder: MessageReminder! - self?.database.write({ session in - let messageReminder = payload.reminder - reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() - }, completion: { error in - if let error { - completion(.failure(error)) - } else { - completion(.success(reminder)) - } - }) - - case .failure(let error): - self?.database.write({ session in - // Restore original value - guard let messageDTO = session.message(id: messageId) else { - return - } - messageDTO.reminder?.remindAt = originalRemindAt?.bridgeDate - }, completion: { _ in - completion(.failure(error)) - }) - } - } - } - } - - /// Deletes a reminder for a message. - /// - Parameters: - /// - messageId: The message identifier for the reminder to delete. - /// - cid: The channel identifier the message belongs to. - /// - completion: Called when the API call is finished. Called with an error if the remote update fails. - func deleteReminder( - messageId: MessageId, - cid: ChannelId, - completion: @escaping ((Error?) -> Void) - ) { - let endpoint: Endpoint = .deleteReminder(messageId: messageId) - - // Save data for potential rollback - var originalPayload: ReminderPayload? - - // First optimistically delete the reminder locally - database.write { session in - // Verify the message exists - guard let messageDTO = session.message(id: messageId) else { - log.warning("Failed to find message with id: \(messageId) for deleting reminder") - return - } - - // Get original reminder data for potential rollback - if let reminderDTO = messageDTO.reminder { - // Store the original state for potential rollback - originalPayload = ReminderPayload( - channelCid: cid, - messageId: messageId, - message: nil, - remindAt: reminderDTO.remindAt?.bridgeDate, - createdAt: reminderDTO.createdAt.bridgeDate, - updatedAt: reminderDTO.updatedAt.bridgeDate - ) - } - - // Delete optimistically - session.deleteReminder(messageId: messageId) - } completion: { [weak self] _ in - // Make the API call to delete the reminder - self?.apiClient.request(endpoint: endpoint) { result in - switch result { - case .success: - completion(nil) - - case .failure(let error): - // Rollback the optimistic delete if the API call fails - guard let originalPayload = originalPayload else { - completion(error) - return - } - - self?.database.write({ session in - // Restore original reminder - do { - try session.saveReminder(payload: originalPayload, cache: nil) - } catch { - log.warning("Failed to rollback reminder deletion: \(error)") - } - }, completion: { _ in - completion(error) - }) - } - } - } - } } extension MessageUpdater { diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index f712a251521..b67cf63cc1c 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1692,9 +1692,13 @@ ADB8B8ED2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; ADB8B8EE2D8890E000549C95 /* MessageReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */; }; ADB8B8F02D8A493900549C95 /* ReminderPayload.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */; }; - ADB8B8F22D8ADA0700549C95 /* ReminderRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */; }; - ADB8B8F32D8ADA0700549C95 /* ReminderRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */; }; + ADB8B8F22D8ADA0700549C95 /* RemindersRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* RemindersRepository.swift */; }; + ADB8B8F32D8ADA0700549C95 /* RemindersRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F12D8ADA0700549C95 /* RemindersRepository.swift */; }; ADB8B8F52D8ADC9400549C95 /* DemoReminderListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */; }; + ADB8B8F72D8B846D00549C95 /* RemindersRepository_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F62D8B846D00549C95 /* RemindersRepository_Tests.swift */; }; + ADB8B8F92D8B8A0C00549C95 /* RemindersRepository_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */; }; + ADB8B8FB2D8B904D00549C95 /* MessageController+Reminders_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */; }; + ADB8B8FD2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */; }; ADB951A1291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951AB291C1DE400800554 /* AttachmentUploader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */; }; @@ -4413,8 +4417,12 @@ ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderDTO.swift; sourceTree = ""; }; ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder.swift; sourceTree = ""; }; ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderPayload.json; sourceTree = ""; }; - ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderRepository.swift; sourceTree = ""; }; + ADB8B8F12D8ADA0700549C95 /* RemindersRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersRepository.swift; sourceTree = ""; }; ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoReminderListVC.swift; sourceTree = ""; }; + ADB8B8F62D8B846D00549C95 /* RemindersRepository_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersRepository_Tests.swift; sourceTree = ""; }; + ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersRepository_Mock.swift; sourceTree = ""; }; + ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageController+Reminders_Tests.swift"; sourceTree = ""; }; + ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUserController+Reminders_Tests.swift"; sourceTree = ""; }; ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader_Spy.swift; sourceTree = ""; }; ADB951AC291C22DB00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; @@ -7334,6 +7342,7 @@ A364D0A327D126490029857A /* Repositories */ = { isa = PBXGroup; children = ( + ADB8B8F62D8B846D00549C95 /* RemindersRepository_Tests.swift */, AD545E782D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift */, C11BAA4C2907EC7B004C5EA4 /* AuthenticationRepository_Tests.swift */, C18514FC292E34E10033387E /* ConnectionRepository_Tests.swift */, @@ -7431,6 +7440,7 @@ A364D0AD27D1291E0029857A /* CurrentUserController */ = { isa = PBXGroup; children = ( + ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */, AD545E822D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift */, F69E7F7C24ED7562000F5252 /* CurrentUserController_Tests.swift */, DA4AA3B5250271B100FAAF6E /* CurrentUserController+Combine_Tests.swift */, @@ -7482,6 +7492,7 @@ isa = PBXGroup; children = ( AD545E802D5D0006008FD399 /* MessageController+Drafts_Tests.swift */, + ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */, F649B2362500F785008F98C8 /* MessageController_Tests.swift */, DAF1BED625066128003CEDC0 /* MessageController+Combine_Tests.swift */, DAF1BED225066107003CEDC0 /* MessageController+SwiftUI_Tests.swift */, @@ -9156,6 +9167,7 @@ C12D0A5E28FD58CE0099895A /* Repositories */ = { isa = PBXGroup; children = ( + ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */, AD545E7E2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift */, C12D0A5F28FD59B60099895A /* AuthenticationRepository_Mock.swift */, A344074E27D753530044F150 /* ConnectionRepository_Mock.swift */, @@ -9290,7 +9302,7 @@ C1E8AD59278DDC500041B775 /* Repositories */ = { isa = PBXGroup; children = ( - ADB8B8F12D8ADA0700549C95 /* ReminderRepository.swift */, + ADB8B8F12D8ADA0700549C95 /* RemindersRepository.swift */, C135A1CA28F45F6B0058EFB6 /* AuthenticationRepository.swift */, 88206FC325B18C88009D086A /* ConnectionRepository.swift */, C1B0B38527BFE8AB00C8207D /* MessageRepository.swift */, @@ -11239,6 +11251,7 @@ A3C3BC8327E8AB6200224761 /* URLSessionConfiguration+Equatable.swift in Sources */, A3C3BC3C27E87F5100224761 /* RetryStrategy_Spy.swift in Sources */, A344078F27D753530044F150 /* ChatChannelListController_Mock.swift in Sources */, + ADB8B8F92D8B8A0C00549C95 /* RemindersRepository_Mock.swift in Sources */, A311B43427E8BC8400CFCF6D /* MessageSearchController_Delegate.swift in Sources */, A3C3BC7427E8AA4300224761 /* TestError.swift in Sources */, A311B43327E8BC8400CFCF6D /* ConnectionController_Delegate.swift in Sources */, @@ -11622,7 +11635,7 @@ C1E8AD5E278EF5F30041B775 /* AsyncOperation.swift in Sources */, 88D85DA7252F3C1D00AE1030 /* MemberListController.swift in Sources */, 79280F4B248523C000CDEB89 /* ConnectionEvents.swift in Sources */, - ADB8B8F32D8ADA0700549C95 /* ReminderRepository.swift in Sources */, + ADB8B8F32D8ADA0700549C95 /* RemindersRepository.swift in Sources */, 79158CF425F133FB00186102 /* ChannelTruncatedEventMiddleware.swift in Sources */, 882C574A252C767E00E60C44 /* ChannelMemberListPayload.swift in Sources */, DAFAD6A324DD8E1A0043ED06 /* ChannelEditDetailPayload.swift in Sources */, @@ -11802,6 +11815,7 @@ 8459C9EE2BFB673E00F0D235 /* PollVoteListController+Combine_Tests.swift in Sources */, 8836FFC325408210009FDF73 /* FlagUserPayload_Tests.swift in Sources */, C18514FD292E34E10033387E /* ConnectionRepository_Tests.swift in Sources */, + ADB8B8F72D8B846D00549C95 /* RemindersRepository_Tests.swift in Sources */, 4FE56B902D5E002A00589F9A /* MarkdownParser_Tests.swift in Sources */, 7964F3AA249A19EA002A09EC /* Filter_Tests.swift in Sources */, 4F5151962BC3DEA1001B7152 /* UserSearch_Tests.swift in Sources */, @@ -12060,6 +12074,7 @@ 8AC9CBD424C7351D006E236C /* ReactionEvents_Tests.swift in Sources */, C11B575629D20F3600D5A248 /* User_Tests.swift in Sources */, 8AC9CBE424C74ECB006E236C /* NotificationEvents_Tests.swift in Sources */, + ADB8B8FD2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift in Sources */, 405D172D2A03E57C00A77C3B /* AVAssetTotalAudioSamples_Tests.swift in Sources */, ADE57B892C3C626100DD6B88 /* ThreadEvents_Tests.swift in Sources */, 84EB4E78276A03DE00E47E73 /* ErrorPayload_Tests.swift in Sources */, @@ -12095,6 +12110,7 @@ AD0F7F1C2B616DD000914C4C /* TextLinkDetector_Tests.swift in Sources */, BCE486580F913CFFDB3B5ECD /* JSONEncoder_Tests.swift in Sources */, BCE48639FD7B6B05CD63A6AF /* FilterDecoding_Tests.swift in Sources */, + ADB8B8FB2D8B904D00549C95 /* MessageController+Reminders_Tests.swift in Sources */, C152F5FE27C65C18003B4805 /* MessageRepository_Tests.swift in Sources */, BCE484BA1EE03FF336034250 /* FilterEncoding_Tests.swift in Sources */, ); @@ -12407,7 +12423,7 @@ C121E852274544AE00023E4C /* ModerationEndpoints.swift in Sources */, C121E853274544AE00023E4C /* WebSocketConnectEndpoint.swift in Sources */, 4F73F39F2B91C7BF00563CD9 /* MessageState+Observer.swift in Sources */, - ADB8B8F22D8ADA0700549C95 /* ReminderRepository.swift in Sources */, + ADB8B8F22D8ADA0700549C95 /* RemindersRepository.swift in Sources */, C121E854274544AE00023E4C /* MemberEndpoints.swift in Sources */, C121E855274544AE00023E4C /* AttachmentEndpoints.swift in Sources */, C121E856274544AE00023E4C /* ChatRemoteNotificationHandler.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 2cc0cca1bd8..55f553d9ce5 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -152,6 +152,7 @@ extension ChatClient { syncRepositoryBuilder: SyncRepository_Mock.init, pollsRepositoryBuilder: PollsRepository_Mock.init, draftMessagesRepositoryBuilder: DraftMessagesRepository_Mock.init, + remindersRepositoryBuilder: RemindersRepository_Mock.init, channelListUpdaterBuilder: ChannelListUpdater_Spy.init, messageRepositoryBuilder: MessageRepository_Mock.init, offlineRequestsRepositoryBuilder: OfflineRequestsRepository_Mock.init @@ -209,6 +210,10 @@ extension ChatClient { draftMessagesRepository as! DraftMessagesRepository_Mock } + var mockRemindersRepository: RemindersRepository_Mock { + remindersRepository as! RemindersRepository_Mock + } + func simulateProvidedConnectionId(connectionId: ConnectionId?) { guard let connectionId = connectionId else { webSocketClient( @@ -247,6 +252,7 @@ extension ChatClient.Environment { syncRepositoryBuilder: SyncRepository_Mock.init, pollsRepositoryBuilder: PollsRepository_Mock.init, draftMessagesRepositoryBuilder: DraftMessagesRepository_Mock.init, + remindersRepositoryBuilder: RemindersRepository_Mock.init, channelListUpdaterBuilder: ChannelListUpdater_Spy.init, messageRepositoryBuilder: MessageRepository_Mock.init, offlineRequestsRepositoryBuilder: OfflineRequestsRepository_Mock.init diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift new file mode 100644 index 00000000000..c98ff59894b --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift @@ -0,0 +1,121 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat +import XCTest + +/// Mock implementation of RemindersRepository +final class RemindersRepository_Mock: RemindersRepository { + var loadReminders_query: MessageReminderListQuery? + var loadReminders_completion: ((Result) -> Void)? + var loadReminders_completion_result: Result? + + var createReminder_messageId: MessageId? + var createReminder_cid: ChannelId? + var createReminder_remindAt: Date? + var createReminder_completion: ((Result) -> Void)? + var createReminder_completion_result: Result? + + var updateReminder_messageId: MessageId? + var updateReminder_cid: ChannelId? + var updateReminder_remindAt: Date? + var updateReminder_completion: ((Result) -> Void)? + var updateReminder_completion_result: Result? + + var deleteReminder_messageId: MessageId? + var deleteReminder_cid: ChannelId? + var deleteReminder_completion: ((Error?) -> Void)? + var deleteReminder_error: Error? + + /// Default initializer + override init(database: DatabaseContainer, apiClient: APIClient) { + super.init(database: database, apiClient: apiClient) + } + + /// Convenience initializer + init() { + super.init(database: DatabaseContainer_Spy(), apiClient: APIClient_Spy()) + } + + // Cleans up all recorded values + func cleanUp() { + loadReminders_query = nil + loadReminders_completion = nil + loadReminders_completion_result = nil + + createReminder_messageId = nil + createReminder_cid = nil + createReminder_remindAt = nil + createReminder_completion = nil + createReminder_completion_result = nil + + updateReminder_messageId = nil + updateReminder_cid = nil + updateReminder_remindAt = nil + updateReminder_completion = nil + updateReminder_completion_result = nil + + deleteReminder_messageId = nil + deleteReminder_cid = nil + deleteReminder_completion = nil + deleteReminder_error = nil + } + + override func loadReminders( + query: MessageReminderListQuery, + completion: @escaping ((Result) -> Void) + ) { + loadReminders_query = query + loadReminders_completion = completion + + if let result = loadReminders_completion_result { + completion(result) + } + } + + override func createReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + createReminder_messageId = messageId + createReminder_cid = cid + createReminder_remindAt = remindAt + createReminder_completion = completion + + if let result = createReminder_completion_result { + completion(result) + } + } + + override func updateReminder( + messageId: MessageId, + cid: ChannelId, + remindAt: Date?, + completion: @escaping ((Result) -> Void) + ) { + updateReminder_messageId = messageId + updateReminder_cid = cid + updateReminder_remindAt = remindAt + updateReminder_completion = completion + + if let result = updateReminder_completion_result { + completion(result) + } + } + + override func deleteReminder( + messageId: MessageId, + cid: ChannelId, + completion: @escaping ((Error?) -> Void) + ) { + deleteReminder_messageId = messageId + deleteReminder_cid = cid + deleteReminder_completion = completion + + completion(deleteReminder_error) + } +} diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift index 45006d8845e..afa56d35bbe 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/MessageUpdater_Mock.swift @@ -133,23 +133,6 @@ final class MessageUpdater_Mock: MessageUpdater { var loadThread_query: ThreadQuery? var loadThread_completion: ((Result) -> Void)? - @Atomic var createReminder_messageId: MessageId? - @Atomic var createReminder_cid: ChannelId? - @Atomic var createReminder_remindAt: Date? - @Atomic var createReminder_completion: ((Result) -> Void)? - @Atomic var createReminder_completion_result: Result? - - @Atomic var updateReminder_messageId: MessageId? - @Atomic var updateReminder_cid: ChannelId? - @Atomic var updateReminder_remindAt: Date? - @Atomic var updateReminder_completion: ((Result) -> Void)? - @Atomic var updateReminder_completion_result: Result? - - @Atomic var deleteReminder_messageId: MessageId? - @Atomic var deleteReminder_cid: ChannelId? - @Atomic var deleteReminder_completion: ((Error?) -> Void)? - @Atomic var deleteReminder_error: Error? - // Cleans up all recorded values func cleanUp() { getMessage_cid = nil @@ -264,23 +247,6 @@ final class MessageUpdater_Mock: MessageUpdater { loadThread_query = nil loadThread_completion = nil - - createReminder_messageId = nil - createReminder_cid = nil - createReminder_remindAt = nil - createReminder_completion = nil - createReminder_completion_result = nil - - updateReminder_messageId = nil - updateReminder_cid = nil - updateReminder_remindAt = nil - updateReminder_completion = nil - updateReminder_completion_result = nil - - deleteReminder_messageId = nil - deleteReminder_cid = nil - deleteReminder_completion = nil - deleteReminder_error = nil } override func getMessage(cid: ChannelId, messageId: MessageId, completion: ((Result) -> Void)? = nil) { @@ -550,46 +516,6 @@ final class MessageUpdater_Mock: MessageUpdater { loadThread_query = query loadThread_completion = completion } - - override func createReminder( - messageId: MessageId, - cid: ChannelId, - remindAt: Date?, - completion: @escaping ((Result) -> Void) - ) { - createReminder_messageId = messageId - createReminder_cid = cid - createReminder_remindAt = remindAt - createReminder_completion = completion - - if let result = createReminder_completion_result { - completion(result) - } - } - - override func updateReminder( - messageId: MessageId, - cid: ChannelId, - remindAt: Date?, - completion: @escaping ((Result) -> Void) - ) { - updateReminder_messageId = messageId - updateReminder_cid = cid - updateReminder_remindAt = remindAt - updateReminder_completion = completion - - if let result = updateReminder_completion_result { - completion(result) - } - } - - override func deleteReminder(messageId: MessageId, cid: ChannelId, completion: @escaping (((any Error)?) -> Void)) { - deleteReminder_messageId = messageId - deleteReminder_cid = cid - deleteReminder_completion = completion - - completion(deleteReminder_error) - } } extension MessageUpdater.MessageSearchResults { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index dbc1fcded25..95afb22e283 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -252,7 +252,7 @@ final class MessageEndpoints_Tests: XCTestCase { func test_queryReminders_buildsCorrectly() { let query = MessageReminderListQuery( - filter: .equal(.channelCid, to: ChannelId.unique), + filter: .equal(.cid, to: ChannelId.unique), sort: [.init(key: .remindAt, isAscending: true)], pageSize: 25 ) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift index 7bebf950796..817fcf43960 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift @@ -342,7 +342,6 @@ final class RemindersQueryPayload_Tests: XCTestCase { } ], "next": "next-page-token", - "prev": "prev-page-token" } """.data(using: .utf8)! @@ -353,7 +352,6 @@ final class RemindersQueryPayload_Tests: XCTestCase { // Verify pagination tokens XCTAssertEqual(payload.next, "next-page-token") - XCTAssertEqual(payload.prev, "prev-page-token") // Verify first reminder details XCTAssertEqual(payload.reminders[0].channelCid.rawValue, "messaging:26D82FB1-5") diff --git a/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift new file mode 100644 index 00000000000..6b21ae0374b --- /dev/null +++ b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift @@ -0,0 +1,322 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class CurrentUserController_Reminders_Tests: XCTestCase { + var client: ChatClient_Mock! + var controller: CurrentChatUserController! + var remindersRepository: RemindersRepository_Mock! + + override func setUp() { + super.setUp() + + client = ChatClient.mock + remindersRepository = client.mockRemindersRepository + + controller = CurrentChatUserController(client: client) + } + + override func tearDown() { + client.cleanUp() + remindersRepository = nil + controller = nil + client = nil + + super.tearDown() + } + + // MARK: - Load Reminders Tests + + func test_loadReminders_whenSuccessful() { + // Create test data + let reminders = [ + MessageReminder( + id: .unique, + remindAt: Date(), + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + ), + MessageReminder( + id: .unique, + remindAt: nil, // "save for later" type reminder + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + ) + ] + + // Setup expectation + let expectation = expectation(description: "loadReminders completion called") + var receivedResult: Result<[MessageReminder], Error>? + + // Call method being tested + controller.loadReminders { result in + receivedResult = result + expectation.fulfill() + } + + // Provide the mock response after the call + remindersRepository.loadReminders_completion?(.success( + ReminderListResponse(reminders: reminders, next: nil) + )) + + // Wait for completion + waitForExpectations(timeout: defaultTimeout) + + // Verify results + XCTAssertEqual(try? receivedResult?.get().count, 2) + XCTAssertEqual(controller.hasLoadedAllReminders, true) + XCTAssertNotNil(remindersRepository.loadReminders_query) + } + + func test_loadReminders_withPagination() { + // Create test data + let reminders = [MessageReminder( + id: .unique, + remindAt: Date(), + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + )] + + // Set up next cursor + let nextCursor = "next_page_token" + + // Setup expectation + let expectation = expectation(description: "loadReminders completion called") + var receivedResult: Result<[MessageReminder], Error>? + + // Call method being tested + let query = MessageReminderListQuery(pageSize: 10) + controller.loadReminders(query: query) { result in + receivedResult = result + expectation.fulfill() + } + + // Provide the mock response after the call + remindersRepository.loadReminders_completion?(.success( + ReminderListResponse(reminders: reminders, next: nextCursor) + )) + + // Wait for completion + waitForExpectations(timeout: defaultTimeout) + + // Verify results + XCTAssertEqual(try? receivedResult?.get().count, 1) + XCTAssertEqual(controller.hasLoadedAllReminders, false) + XCTAssertEqual(remindersRepository.loadReminders_query?.pagination.pageSize, 10) + } + + func test_loadReminders_whenFailure() { + // Mock repository error + let testError = TestError() + + // Setup expectation + let expectation = expectation(description: "loadReminders completion called") + var receivedError: Error? + + // Call method being tested + controller.loadReminders { result in + if case let .failure(error) = result { + receivedError = error + } + expectation.fulfill() + } + + // Provide the mock error response after the call + remindersRepository.loadReminders_completion?(.failure(testError)) + + // Wait for completion + waitForExpectations(timeout: defaultTimeout) + + // Verify error is passed through + XCTAssertEqual(receivedError as? TestError, testError) + } + + // MARK: - Load More Reminders Tests + + func test_loadMoreReminders_whenSuccessful() { + // First load initial page + let initialReminders = [MessageReminder( + id: .unique, + remindAt: Date(), + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + )] + let nextCursor = "test_cursor" + + // Call initial load + controller.loadReminders { _ in } + remindersRepository.loadReminders_completion?(.success( + ReminderListResponse(reminders: initialReminders, next: nextCursor) + )) + + // Create test data for second page + let moreReminders = [MessageReminder( + id: .unique, + remindAt: Date(), + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + )] + + // Setup expectation for loadMoreReminders + let expectation = expectation(description: "loadMoreReminders completion called") + var receivedResult: Result<[MessageReminder], Error>? + + // Call method being tested + controller.loadMoreReminders(limit: 20) { result in + receivedResult = result + expectation.fulfill() + } + + // Provide the mock response after the call + remindersRepository.loadReminders_completion?(.success( + ReminderListResponse(reminders: moreReminders, next: nil) + )) + + // Wait for completion + waitForExpectations(timeout: defaultTimeout) + + // Verify results + XCTAssertEqual(try? receivedResult?.get().count, 1) + XCTAssertEqual(controller.hasLoadedAllReminders, true) + } + + func test_loadMoreReminders_withNoCursor() { + // Setup expectation + let expectation = expectation(description: "loadMoreReminders completion called") + var receivedResult: Result<[MessageReminder], Error>? + + // Call method being tested with no cursor set + controller.loadMoreReminders { result in + receivedResult = result + expectation.fulfill() + } + + // Wait for completion + waitForExpectations(timeout: defaultTimeout) + + // Verify no API call was made and empty result returned + XCTAssertEqual(try? receivedResult?.get().count, 0) + } + + func test_loadMoreReminders_whenFailure() { + // First load initial page + let initialReminders = [MessageReminder( + id: .unique, + remindAt: Date(), + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + )] + let nextCursor = "test_cursor" + + // Call initial load + controller.loadReminders { _ in } + remindersRepository.loadReminders_completion?(.success( + ReminderListResponse(reminders: initialReminders, next: nextCursor) + )) + + // Setup error for next page + let testError = TestError() + + // Setup expectation + let expectation = expectation(description: "loadMoreReminders completion called") + var receivedError: Error? + + // Call method being tested + controller.loadMoreReminders { result in + if case let .failure(error) = result { + receivedError = error + } + expectation.fulfill() + } + + // Provide the mock error response after the call + remindersRepository.loadReminders_completion?(.failure(testError)) + + // Wait for completion + waitForExpectations(timeout: defaultTimeout) + + // Verify error is passed through + XCTAssertEqual(receivedError as? TestError, testError) + } + + // MARK: - Delegate Tests + + func test_messageRemindersObserver_notifiesDelegate() throws { + class DelegateMock: CurrentChatUserControllerDelegate { + var reminders: [MessageReminder] = [] + let expectation = XCTestExpectation(description: "Did Change Message Reminders") + let expectedRemindersCount: Int + + init(expectedRemindersCount: Int) { + self.expectedRemindersCount = expectedRemindersCount + } + + func currentUserController( + _ controller: CurrentChatUserController, + didChangeMessageReminders reminders: [MessageReminder] + ) { + self.reminders = reminders + guard expectedRemindersCount == reminders.count else { return } + expectation.fulfill() + } + } + + let delegate = DelegateMock(expectedRemindersCount: 2) + controller.loadReminders() + controller.delegate = delegate + + try client.databaseContainer.writeSynchronously { session in + let date = Date.unique + let cid = ChannelId.unique + let messageId1 = MessageId.unique + let messageId2 = MessageId.unique + + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + try session.saveMessage(payload: .dummy(messageId: messageId1), for: cid, syncOwnReactions: false, cache: nil) + try session.saveMessage(payload: .dummy(messageId: messageId2), for: cid, syncOwnReactions: false, cache: nil) + + // Create test reminders with different dates + let reminders = [ + ReminderPayload( + channelCid: cid, + messageId: messageId1, + remindAt: date, + createdAt: date, + updatedAt: date + ), + ReminderPayload( + channelCid: cid, + messageId: messageId2, + remindAt: date.addingTimeInterval(3600), // 1 hour later + createdAt: date, + updatedAt: date + ) + ] + + try reminders.forEach { + try session.saveReminder(payload: $0, cache: nil) + } + } + + wait(for: [delegate.expectation], timeout: defaultTimeout) + XCTAssertEqual(controller.messageReminders.count, 2) + XCTAssertEqual(delegate.reminders.count, 2) + } +} diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController+Reminders_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController+Reminders_Tests.swift new file mode 100644 index 00000000000..610f0b79427 --- /dev/null +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController+Reminders_Tests.swift @@ -0,0 +1,207 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class MessageController_Reminders_Tests: XCTestCase { + var client: ChatClient_Mock! + var controller: ChatMessageController! + var remindersRepository: RemindersRepository_Mock! + + override func setUp() { + super.setUp() + + client = ChatClient.mock + remindersRepository = client.remindersRepository as? RemindersRepository_Mock + + let cid = ChannelId.unique + let messageId = MessageId.unique + controller = ChatMessageController( + client: client, + cid: cid, + messageId: messageId, + replyPaginationHandler: MessagesPaginationStateHandler_Mock() + ) + } + + override func tearDown() { + client.cleanUp() + remindersRepository = nil + controller = nil + client = nil + + super.tearDown() + } + + // MARK: - Create Reminder Tests + + func test_createReminder_whenSuccessful() { + // Prepare data for mocking + let remindAt = Date() + let reminderResponse = MessageReminder( + id: controller.messageId, + remindAt: remindAt, + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + ) + + // Setup mock response + remindersRepository.createReminder_completion_result = .success(reminderResponse) + + // Setup callback verification + let expectation = expectation(description: "createReminder completion called") + var receivedResult: Result? + + // Call method being tested + controller.createReminder(remindAt: remindAt) { result in + receivedResult = result + expectation.fulfill() + } + + // Wait for callback + waitForExpectations(timeout: defaultTimeout) + + // Assert remindersRepository is called with correct params + XCTAssertEqual(remindersRepository.createReminder_messageId, controller.messageId) + XCTAssertEqual(remindersRepository.createReminder_cid, controller.cid) + XCTAssertEqual(remindersRepository.createReminder_remindAt, remindAt) + XCTAssertEqual(receivedResult?.value?.id, reminderResponse.id) + } + + func test_createReminder_whenFailure() { + // Setup mock error response + let testError = TestError() + remindersRepository.createReminder_completion_result = .failure(testError) + + // Setup callback verification + let expectation = expectation(description: "createReminder completion called") + var receivedError: Error? + + // Call method being tested + controller.createReminder(remindAt: nil) { result in + if case let .failure(error) = result { + receivedError = error + } + expectation.fulfill() + } + + // Wait for callback + waitForExpectations(timeout: defaultTimeout) + + // Assert callback is called with correct error + XCTAssertEqual(receivedError as? TestError, testError) + } + + // MARK: - Update Reminder Tests + + func test_updateReminder_whenSuccessful() { + // Prepare data for mocking + let remindAt = Date() + let reminderResponse = MessageReminder( + id: controller.messageId, + remindAt: remindAt, + message: .mock(), + channel: .mockDMChannel(), + createdAt: .init(), + updatedAt: .init() + ) + + // Setup mock response + remindersRepository.updateReminder_completion_result = .success(reminderResponse) + + // Setup callback verification + let expectation = expectation(description: "updateReminder completion called") + var receivedResult: Result? + + // Call method being tested + controller.updateReminder(remindAt: remindAt) { result in + receivedResult = result + expectation.fulfill() + } + + // Wait for callback + waitForExpectations(timeout: defaultTimeout) + + // Assert remindersRepository is called with correct params + XCTAssertEqual(remindersRepository.updateReminder_messageId, controller.messageId) + XCTAssertEqual(remindersRepository.updateReminder_cid, controller.cid) + XCTAssertEqual(remindersRepository.updateReminder_remindAt, remindAt) + XCTAssertEqual(receivedResult?.value?.id, reminderResponse.id) + } + + func test_updateReminder_whenFailure() { + // Setup mock error response + let testError = TestError() + remindersRepository.updateReminder_completion_result = .failure(testError) + + // Setup callback verification + let expectation = expectation(description: "updateReminder completion called") + var receivedError: Error? + + // Call method being tested + controller.updateReminder(remindAt: nil) { result in + if case let .failure(error) = result { + receivedError = error + } + expectation.fulfill() + } + + // Wait for callback + waitForExpectations(timeout: defaultTimeout) + + // Assert callback is called with correct error + XCTAssertEqual(receivedError as? TestError, testError) + } + + // MARK: - Delete Reminder Tests + + func test_deleteReminder_whenSuccessful() { + // Setup mock response + remindersRepository.deleteReminder_error = nil + + // Setup callback verification + let expectation = expectation(description: "deleteReminder completion called") + var receivedError: Error? + + // Call method being tested + controller.deleteReminder { error in + receivedError = error + expectation.fulfill() + } + + // Wait for callback + waitForExpectations(timeout: defaultTimeout) + + // Assert remindersRepository is called with correct params + XCTAssertEqual(remindersRepository.deleteReminder_messageId, controller.messageId) + XCTAssertEqual(remindersRepository.deleteReminder_cid, controller.cid) + XCTAssertNil(receivedError) + } + + func test_deleteReminder_whenFailure() { + // Setup mock error response + let testError = TestError() + remindersRepository.deleteReminder_error = testError + + // Setup callback verification + let expectation = expectation(description: "deleteReminder completion called") + var receivedError: Error? + + // Call method being tested + controller.deleteReminder { error in + receivedError = error + expectation.fulfill() + } + + // Wait for callback + waitForExpectations(timeout: defaultTimeout) + + // Assert callback is called with correct error + XCTAssertEqual(receivedError as? TestError, testError) + } +} diff --git a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift index ed5229dfa3b..6156818365c 100644 --- a/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MessageController/MessageController_Tests.swift @@ -2583,177 +2583,6 @@ final class MessageController_Tests: XCTestCase { delegate.didChangeRepliesExpectedCount = count wait(for: [expectation], timeout: defaultTimeout) } - - // MARK: - Reminder Methods - - func test_createReminder_propagatesSuccessResult() { - // Prepare data for mocking - let remindAt = Date() - let reminderResponse = MessageReminder( - id: messageId, - remindAt: remindAt, - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - ) - - // Setup mock response from message updater - env.messageUpdater?.createReminder_completion_result = .success(reminderResponse) - - // Setup callback verification - let exp = expectation(description: "createReminder callback should be called") - var receivedResult: Result? - - // Call method being tested - controller.createReminder(remindAt: remindAt) { result in - receivedResult = result - exp.fulfill() - } - - // Wait for callback - wait(for: [exp], timeout: defaultTimeout) - - // Assert messageUpdater is called with correct params - XCTAssertNotNil(env.messageUpdater) - XCTAssertEqual(env.messageUpdater?.createReminder_messageId, messageId) - XCTAssertEqual(env.messageUpdater?.createReminder_cid, cid) - XCTAssertEqual(env.messageUpdater?.createReminder_remindAt, remindAt) - XCTAssertEqual(receivedResult?.value?.id, reminderResponse.id) - } - - func test_createReminder_propagatesFailureResult() { - // Setup mock error response from message updater - let testError = TestError() - env.messageUpdater?.createReminder_completion_result = .failure(testError) - - // Setup callback verification - let exp = expectation(description: "createReminder callback should be called") - var receivedError: Error? - - // Call method being tested - controller.createReminder(remindAt: nil) { result in - if case let .failure(error) = result { - receivedError = error - } - exp.fulfill() - } - - // Wait for callback - wait(for: [exp], timeout: defaultTimeout) - - // Assert callback is called with correct error - XCTAssertEqual(receivedError as? TestError, testError) - } - - func test_updateReminder_propagatesSuccessResult() { - // Prepare data for mocking - let remindAt = Date() - let reminderResponse = MessageReminder( - id: messageId, - remindAt: remindAt, - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - ) - - // Setup mock response from message updater - env.messageUpdater?.updateReminder_completion_result = .success(reminderResponse) - - // Setup callback verification - let exp = expectation(description: "updateReminder callback should be called") - var receivedResult: Result? - - // Call method being tested - controller.updateReminder(remindAt: remindAt) { result in - receivedResult = result - exp.fulfill() - } - - // Wait for callback - wait(for: [exp], timeout: defaultTimeout) - - // Assert messageUpdater is called with correct params - XCTAssertNotNil(env.messageUpdater) - XCTAssertEqual(env.messageUpdater?.updateReminder_messageId, messageId) - XCTAssertEqual(env.messageUpdater?.updateReminder_cid, cid) - XCTAssertEqual(env.messageUpdater?.updateReminder_remindAt, remindAt) - - // Assert callback is called on the callback queue - XCTAssertEqual(receivedResult?.value?.id, reminderResponse.id) - } - - func test_updateReminder_propagatesFailureResult() { - // Setup mock error response from message updater - let testError = TestError() - env.messageUpdater?.updateReminder_completion_result = .failure(testError) - - // Setup callback verification - let exp = expectation(description: "updateReminder callback should be called") - var receivedError: Error? - - // Call method being tested - controller.updateReminder(remindAt: nil) { result in - if case let .failure(error) = result { - receivedError = error - } - exp.fulfill() - } - - // Wait for callback - wait(for: [exp], timeout: defaultTimeout) - - // Assert callback is called with correct error - XCTAssertEqual(receivedError as? TestError, testError) - } - - func test_deleteReminder_propagatesSuccess() { - // Setup mock response from message updater - env.messageUpdater?.deleteReminder_error = nil - - // Setup callback verification - let exp = expectation(description: "deleteReminder callback should be called") - var receivedError: Error? - - // Call method being tested - controller.deleteReminder { error in - receivedError = error - exp.fulfill() - } - - // Wait for callback - wait(for: [exp], timeout: defaultTimeout) - - // Assert messageUpdater is called with correct params - XCTAssertEqual(env.messageUpdater?.deleteReminder_messageId, messageId) - XCTAssertEqual(env.messageUpdater?.deleteReminder_cid, cid) - - // Assert callback is called with nil error on success - XCTAssertNil(receivedError) - } - - func test_deleteReminder_propagatesError() { - // Setup mock error response from message updater - let testError = TestError() - env.messageUpdater?.deleteReminder_error = testError - - // Setup callback verification - let exp = expectation(description: "deleteReminder callback should be called") - var receivedError: Error? - - // Call method being tested - controller.deleteReminder { error in - receivedError = error - exp.fulfill() - } - - // Wait for callback - wait(for: [exp], timeout: defaultTimeout) - - // Assert callback is called with the error - XCTAssertEqual(receivedError as? TestError, testError) - } } private class TestDelegate: QueueAwareDelegate, ChatMessageControllerDelegate { diff --git a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift index c51f6947399..069d5f5bcf5 100644 --- a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift +++ b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift @@ -16,22 +16,16 @@ final class MessageReminderListQuery_Tests: XCTestCase { XCTAssertEqual(query.sort.count, 1) XCTAssertEqual(query.sort[0].key, .remindAt) XCTAssertTrue(query.sort[0].isAscending) - XCTAssertNil(query.next) - XCTAssertNil(query.prev) } func test_customInitialization() { - let filter = Filter.equal(.channelCid, to: ChannelId.unique) + let filter = Filter.equal(.cid, to: ChannelId.unique) let sort = [Sorting(key: .createdAt, isAscending: false)] - let next = "next-token" - let prev = "prev-token" let query = MessageReminderListQuery( filter: filter, sort: sort, - pageSize: 10, - next: next, - prev: prev + pageSize: 10 ) XCTAssertEqual(query.filter?.filterHash, filter.filterHash) @@ -39,30 +33,22 @@ final class MessageReminderListQuery_Tests: XCTestCase { XCTAssertEqual(query.sort.count, 1) XCTAssertEqual(query.sort[0].key, .createdAt) XCTAssertFalse(query.sort[0].isAscending) - XCTAssertEqual(query.next, next) - XCTAssertEqual(query.prev, prev) } func test_encode_withAllFields() throws { - let filter = Filter.equal(.channelCid, to: ChannelId.unique) + let filter = Filter.equal(.cid, to: ChannelId.unique) let sort = [Sorting(key: .createdAt, isAscending: false)] - let next = "next-token" - let prev = "prev-token" let query = MessageReminderListQuery( filter: filter, sort: sort, - pageSize: 10, - next: next, - prev: prev + pageSize: 10 ) let expectedData: [String: Any] = [ "filter": ["channel_cid": ["$eq": filter.value]], "sort": [["field": "created_at", "direction": -1]], - "limit": 10, - "next": next, - "prev": prev + "limit": 10 ] let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) @@ -90,8 +76,8 @@ final class MessageReminderListQuery_Tests: XCTestCase { } func test_encode_withoutSort() throws { - let filter = Filter.equal(.channelCid, to: ChannelId.unique) - + let filter = Filter.equal(.cid, to: ChannelId.unique) + let query = MessageReminderListQuery( filter: filter, sort: [], @@ -110,7 +96,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { func test_filterKeys() { // Test the filter keys for proper values - XCTAssertEqual(FilterKey.channelCid.rawValue, "channel_cid") + XCTAssertEqual(FilterKey.cid.rawValue, "channel_cid") XCTAssertEqual(FilterKey.messageId.rawValue, "message_id") XCTAssertEqual(FilterKey.remindAt.rawValue, "remind_at") XCTAssertEqual(FilterKey.createdAt.rawValue, "created_at") diff --git a/Tests/StreamChatTests/Repositories/RemindersRepository_Tests.swift b/Tests/StreamChatTests/Repositories/RemindersRepository_Tests.swift new file mode 100644 index 00000000000..545214eb04e --- /dev/null +++ b/Tests/StreamChatTests/Repositories/RemindersRepository_Tests.swift @@ -0,0 +1,562 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class RemindersRepository_Tests: XCTestCase { + var database: DatabaseContainer_Spy! + var apiClient: APIClient_Spy! + var repository: RemindersRepository! + + override func setUp() { + super.setUp() + + let client = ChatClient.mock + database = client.mockDatabaseContainer + apiClient = client.mockAPIClient + repository = RemindersRepository(database: database, apiClient: apiClient) + } + + override func tearDown() { + super.tearDown() + + apiClient.cleanUp() + apiClient = nil + database = nil + repository = nil + } + + // MARK: - Load Reminders Tests + + func test_loadReminders_makesCorrectAPICall() { + // Prepare data for the test + let query = MessageReminderListQuery( + filter: .equal(.remindAt, to: Date()), + sort: [.init(key: .remindAt, isAscending: true)] + ) + + // Simulate `loadReminders` call + let exp = expectation(description: "completion is called") + repository.loadReminders(query: query) { _ in + exp.fulfill() + } + + // Mock response + let response = RemindersQueryPayload( + reminders: [], + next: nil + ) + + apiClient.test_simulateResponse(.success(response)) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .queryReminders(query: query) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_loadReminders_savesRemindersToDatabase() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date().addingTimeInterval(3600) // 1 hour from now + let createdAt = Date().addingTimeInterval(-3600) // 1 hour ago + let updatedAt = Date() + + let query = MessageReminderListQuery( + filter: .equal(.remindAt, to: Date()), + sort: [.init(key: .remindAt, isAscending: true)] + ) + + // Create a reminder payload + let reminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: remindAt, + createdAt: createdAt, + updatedAt: updatedAt + ) + + let response = RemindersQueryPayload( + reminders: [reminderPayload], + next: nil + ) + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `loadReminders` call + var result: Result? + let exp = expectation(description: "completion is called") + repository.loadReminders(query: query) { receivedResult in + result = receivedResult + exp.fulfill() + } + + apiClient.test_simulateResponse(.success(response)) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert response is parsed correctly + guard case .success(let reminderResponse) = result else { + XCTFail("Expected successful result") + return + } + + XCTAssertEqual(reminderResponse.reminders.count, 1) + XCTAssertEqual(reminderResponse.reminders.first?.id, messageId) + XCTAssertNearlySameDate(reminderResponse.reminders.first?.remindAt, remindAt) + + // Assert reminder is saved to database + var savedReminder: MessageReminder? + try database.writeSynchronously { session in + savedReminder = try session.message(id: messageId)?.reminder?.asModel() + } + + XCTAssertNotNil(savedReminder) + XCTAssertNearlySameDate(savedReminder?.remindAt, remindAt) + } + + func test_loadReminders_propagatesAPIError() { + // Prepare data for the test + let query = MessageReminderListQuery( + filter: .equal(.remindAt, to: Date()), + sort: [.init(key: .remindAt, isAscending: true)] + ) + + // Simulate `loadReminders` call + var result: Result? + let exp = expectation(description: "completion is called") + repository.loadReminders(query: query) { receivedResult in + result = receivedResult + exp.fulfill() + } + + let testError = TestError() + apiClient.test_simulateResponse(Result.failure(testError)) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert error is propagated correctly + guard case .failure = result else { + XCTFail("Expected failure result") + return + } + } + + // MARK: - Create Reminder Tests + + func test_createReminder_makesCorrectAPICall() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date() + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `createReminder` call + let exp = expectation(description: "completion is called") + repository.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { _ in + exp.fulfill() + } + + apiClient.test_mockResponseResult(.success(ReminderResponsePayload( + reminder: .init( + channelCid: cid, + messageId: messageId, + remindAt: remindAt, + createdAt: .unique, + updatedAt: .unique + ) + ))) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .createReminder( + messageId: messageId, + request: ReminderRequestBody(remindAt: remindAt) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_createReminder_updatesLocalMessageOptimistically() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date() + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `createReminder` call + repository.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { _ in } + + // Assert reminder was created locally + var expectedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + expectedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(expectedRemindAt, remindAt) + } + + func test_createReminder_rollsBackOnFailure() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let remindAt = Date() + + // Create a message to add reminder to + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + // Simulate `createReminder` call + let exp = expectation(description: "completion is called") + repository.createReminder( + messageId: messageId, + cid: cid, + remindAt: remindAt + ) { _ in + exp.fulfill() + } + + // Assert reminder was created locally + var expectedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + expectedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(expectedRemindAt, remindAt) + + apiClient.test_simulateResponse(Result.failure(TestError())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert reminder was rolled back + var actualRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + actualRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNil(actualRemindAt) + } + + // MARK: - Update Reminder Tests + + func test_updateReminder_makesCorrectAPICall() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now + + // Create a message with an existing reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `updateReminder` call + let exp = expectation(description: "completion is called") + repository.updateReminder( + messageId: messageId, + cid: cid, + remindAt: newRemindAt + ) { _ in + exp.fulfill() + } + + apiClient.test_mockResponseResult(.success(ReminderResponsePayload( + reminder: .init( + channelCid: cid, + messageId: messageId, + remindAt: newRemindAt, + createdAt: .unique, + updatedAt: .unique + ) + ))) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .updateReminder( + messageId: messageId, + request: ReminderRequestBody(remindAt: newRemindAt) + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_updateReminder_updatesLocalMessageOptimistically() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now + + // Create a message with an existing reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `updateReminder` call + repository.updateReminder( + messageId: messageId, + cid: cid, + remindAt: newRemindAt + ) { _ in } + + // Assert reminder was updated locally (optimistically) + var updatedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + updatedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(updatedRemindAt, newRemindAt) + } + + func test_updateReminder_rollsBackOnFailure() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + let originalRemindAt = Date().addingTimeInterval(-3600) // 1 hour ago + let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now + + // Create a message with an existing reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: originalRemindAt, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `updateReminder` call + let exp = expectation(description: "completion is called") + repository.updateReminder( + messageId: messageId, + cid: cid, + remindAt: newRemindAt + ) { _ in + exp.fulfill() + } + + // Assert reminder was updated locally (optimistically) + var updatedRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + updatedRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(updatedRemindAt, newRemindAt) + + // Simulate API failure + apiClient.test_simulateResponse(Result.failure(TestError())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert reminder was rolled back to original state + var rolledBackRemindAt: Date? + try database.writeSynchronously { session in + let message = session.message(id: messageId) + rolledBackRemindAt = message?.reminder?.remindAt?.bridgeDate + } + XCTAssertNearlySameDate(rolledBackRemindAt, originalRemindAt) + } + + // MARK: - Delete Reminder Tests + + func test_deleteReminder_makesCorrectAPICall() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + + // Create a message with reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Simulate `deleteReminder` call + let exp = expectation(description: "completion is called") + repository.deleteReminder( + messageId: messageId, + cid: cid + ) { _ in + exp.fulfill() + } + + apiClient.test_mockResponseResult(.success(EmptyResponse())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert endpoint is correct + let expectedEndpoint: Endpoint = .deleteReminder(messageId: messageId) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) + } + + func test_deleteReminder_deletesLocalReminderOptimistically() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + + // Create a message with reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Verify reminder exists before deletion + var hasReminderBefore = false + try database.writeSynchronously { session in + hasReminderBefore = session.message(id: messageId)?.reminder != nil + } + XCTAssertTrue(hasReminderBefore, "Message should have a reminder before deletion") + + // Simulate `deleteReminder` call + repository.deleteReminder( + messageId: messageId, + cid: cid + ) { _ in } + + // Assert reminder was deleted locally (optimistically) + var hasReminderAfter = true + try database.writeSynchronously { session in + hasReminderAfter = session.message(id: messageId)?.reminder != nil + } + XCTAssertFalse(hasReminderAfter, "Reminder should be optimistically deleted locally") + } + + func test_deleteReminder_rollsBackOnFailure() throws { + // Prepare data for the test + let messageId: MessageId = .unique + let cid: ChannelId = .unique + + // Create a message with reminder + try database.createMessage(id: messageId, cid: cid, text: "Test message") + + try database.writeSynchronously { session in + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: messageId, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) + } + + // Store original reminder values for later comparison + var originalRemindAt: Date? + var originalCreatedAt: Date? + var originalUpdatedAt: Date? + + try database.writeSynchronously { session in + guard let reminder = session.message(id: messageId)?.reminder else { return } + originalRemindAt = reminder.remindAt?.bridgeDate + originalCreatedAt = reminder.createdAt.bridgeDate + originalUpdatedAt = reminder.updatedAt.bridgeDate + } + + // Simulate `deleteReminder` call + let exp = expectation(description: "completion is called") + repository.deleteReminder( + messageId: messageId, + cid: cid + ) { _ in + exp.fulfill() + } + + // Verify reminder was optimistically deleted + var hasReminderAfterDelete = true + try database.writeSynchronously { session in + hasReminderAfterDelete = session.message(id: messageId)?.reminder != nil + } + XCTAssertFalse(hasReminderAfterDelete, "Reminder should be optimistically deleted") + + // Simulate API failure + apiClient.test_simulateResponse(Result.failure(TestError())) + + wait(for: [exp], timeout: defaultTimeout) + + // Assert reminder was restored with original values + var restoredRemindAt: Date? + var restoredCreatedAt: Date? + var restoredUpdatedAt: Date? + + try database.writeSynchronously { session in + guard let reminder = session.message(id: messageId)?.reminder else { + XCTFail("Reminder should be restored after API failure") + return + } + + restoredRemindAt = reminder.remindAt?.bridgeDate + restoredCreatedAt = reminder.createdAt.bridgeDate + restoredUpdatedAt = reminder.updatedAt.bridgeDate + } + + XCTAssertNearlySameDate(restoredRemindAt, originalRemindAt) + XCTAssertNearlySameDate(restoredCreatedAt, originalCreatedAt) + XCTAssertNearlySameDate(restoredUpdatedAt, originalUpdatedAt) + } +} diff --git a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift index 9c3407c312d..a9659877729 100644 --- a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift @@ -2974,471 +2974,6 @@ final class MessageUpdater_Tests: XCTestCase { wait(for: [exp], timeout: defaultTimeout) } - - // MARK: - Message Reminders - - func test_createReminder_makesCorrectAPICall() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let remindAt = Date() - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a message to add reminder to - try database.createMessage(id: messageId, cid: cid, text: "Test message") - - // Simulate `createReminder` call - let exp = expectation(description: "completion is called") - messageUpdater.createReminder( - messageId: messageId, - cid: cid, - remindAt: remindAt - ) { _ in - exp.fulfill() - } - - apiClient.test_mockResponseResult(.success(ReminderResponsePayload( - reminder: .init( - channelCid: cid, - messageId: messageId, - remindAt: remindAt, - createdAt: .unique, - updatedAt: .unique - ) - ))) - - wait(for: [exp], timeout: defaultTimeout) - - // Assert endpoint is correct - let expectedEndpoint: Endpoint = .createReminder( - messageId: messageId, - request: ReminderRequestBody(remindAt: remindAt) - ) - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) - } - - func test_createReminder_updatesLocalMessageOptimistically() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let remindAt = Date() - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message to add reminder to - try database.createMessage(id: messageId, cid: cid, text: "Test message") - - // Simulate `createReminder` call - messageUpdater.createReminder( - messageId: messageId, - cid: cid, - remindAt: remindAt - ) { _ in } - - // Assert reminder was created locally - var expectedRemindAt: Date? - try database.writeSynchronously { session in - let message = session.message(id: messageId) - expectedRemindAt = message?.reminder?.remindAt?.bridgeDate - } - XCTAssertNearlySameDate(expectedRemindAt, remindAt) - } - - func test_createReminder_rollsBackOnFailure() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let remindAt = Date() - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message to add reminder to - try database.createMessage(id: messageId, cid: cid, text: "Test message") - - // Simulate `createReminder` call - let exp = expectation(description: "completion is called") - messageUpdater.createReminder( - messageId: messageId, - cid: cid, - remindAt: remindAt - ) { _ in - exp.fulfill() - } - - // Assert reminder was created locally - var expectedRemindAt: Date? - try database.writeSynchronously { session in - let message = session.message(id: messageId) - expectedRemindAt = message?.reminder?.remindAt?.bridgeDate - } - XCTAssertNearlySameDate(expectedRemindAt, remindAt) - - apiClient.test_simulateResponse(Result.failure(TestError())) - - wait(for: [exp], timeout: defaultTimeout) - - // Assert reminder was rolled back - var actualRemindAt: Date? - try database.writeSynchronously { session in - let message = session.message(id: messageId) - actualRemindAt = message?.reminder?.remindAt?.bridgeDate - } - XCTAssertNil(actualRemindAt) - } - - func test_updateReminder_makesCorrectAPICall() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message with an existing reminder - try database.createMessage(id: messageId, cid: cid, text: "Test message") - let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) - - try database.writeSynchronously { session in - try session.saveReminder( - payload: .init( - channelCid: cid, - messageId: messageId, - remindAt: .unique, - createdAt: .unique, - updatedAt: .unique - ), - cache: nil - ) - } - - // Simulate `updateReminder` call - let exp = expectation(description: "completion is called") - messageUpdater.updateReminder( - messageId: messageId, - cid: cid, - remindAt: newRemindAt - ) { _ in - exp.fulfill() - } - - apiClient.test_mockResponseResult(.success(ReminderResponsePayload( - reminder: .init( - channelCid: cid, - messageId: messageId, - remindAt: newRemindAt, - createdAt: .unique, - updatedAt: .unique - ) - ))) - - wait(for: [exp], timeout: defaultTimeout) - - // Assert endpoint is correct - let expectedEndpoint: Endpoint = .updateReminder( - messageId: messageId, - request: ReminderRequestBody(remindAt: newRemindAt) - ) - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) - } - - func test_updateReminder_updatesLocalMessageOptimistically() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message with an existing reminder - try database.createMessage(id: messageId, cid: cid, text: "Test message") - let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) - - try database.writeSynchronously { session in - try session.saveReminder( - payload: .init( - channelCid: cid, - messageId: messageId, - remindAt: .unique, - createdAt: .unique, - updatedAt: .unique - ), - cache: nil - ) - } - - // Simulate `updateReminder` call - messageUpdater.updateReminder( - messageId: messageId, - cid: cid, - remindAt: newRemindAt - ) { _ in } - - // Assert reminder was updated locally (optimistically) - var updatedRemindAt: Date? - try database.writeSynchronously { session in - let message = session.message(id: messageId) - updatedRemindAt = message?.reminder?.remindAt?.bridgeDate - } - XCTAssertNearlySameDate(updatedRemindAt, newRemindAt) - } - - func test_updateReminder_rollsBackOnFailure() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let originalRemindAt = Date().addingTimeInterval(-3600) // 1 hour ago - let newRemindAt = Date().addingTimeInterval(3600) // 1 hour from now - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message with an existing reminder - try database.createMessage(id: messageId, cid: cid, text: "Test message") - let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) - - try database.writeSynchronously { session in - try session.saveReminder( - payload: .init( - channelCid: cid, - messageId: messageId, - remindAt: originalRemindAt, - createdAt: .unique, - updatedAt: .unique - ), - cache: nil - ) - } - - // Simulate `updateReminder` call - let exp = expectation(description: "completion is called") - messageUpdater.updateReminder( - messageId: messageId, - cid: cid, - remindAt: newRemindAt - ) { _ in - exp.fulfill() - } - - // Assert reminder was updated locally (optimistically) - var updatedRemindAt: Date? - try database.writeSynchronously { session in - let message = session.message(id: messageId) - updatedRemindAt = message?.reminder?.remindAt?.bridgeDate - } - XCTAssertNearlySameDate(updatedRemindAt, newRemindAt) - - // Simulate API failure - apiClient.test_simulateResponse(Result.failure(TestError())) - - wait(for: [exp], timeout: defaultTimeout) - - // Assert reminder was rolled back to original state - var rolledBackRemindAt: Date? - try database.writeSynchronously { session in - let message = session.message(id: messageId) - rolledBackRemindAt = message?.reminder?.remindAt?.bridgeDate - } - XCTAssertNearlySameDate(rolledBackRemindAt, originalRemindAt) - } - - func test_deleteReminder_makesCorrectAPICall() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message with reminder - try database.createMessage(id: messageId, cid: cid, text: "Test message") - let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) - - try database.writeSynchronously { session in - let reminderDTO = session.message(id: messageId)?.reminder - messageDTO.reminder = reminderDTO - } - - // Simulate `deleteReminder` call - let exp = expectation(description: "completion is called") - messageUpdater.deleteReminder( - messageId: messageId, - cid: cid - ) { _ in - exp.fulfill() - } - - apiClient.test_mockResponseResult(.success(EmptyResponse())) - - wait(for: [exp], timeout: defaultTimeout) - - // Assert endpoint is correct - let expectedEndpoint: Endpoint = .deleteReminder(messageId: messageId) - XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(expectedEndpoint)) - } - - func test_deleteReminder_deletesLocalReminderOptimistically() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message with reminder - try database.createMessage(id: messageId, cid: cid, text: "Test message") - let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) - - try database.writeSynchronously { session in - try session.saveReminder( - payload: .init( - channelCid: cid, - messageId: messageId, - remindAt: .unique, - createdAt: .unique, - updatedAt: .unique - ), - cache: nil - ) - } - - // Verify reminder exists before deletion - var hasReminderBefore = false - try database.writeSynchronously { session in - hasReminderBefore = session.message(id: messageId)?.reminder != nil - } - XCTAssertTrue(hasReminderBefore, "Message should have a reminder before deletion") - - // Simulate `deleteReminder` call - messageUpdater.deleteReminder( - messageId: messageId, - cid: cid - ) { _ in } - - // Assert reminder was deleted locally (optimistically) - var hasReminderAfter = true - try database.writeSynchronously { session in - hasReminderAfter = session.message(id: messageId)?.reminder != nil - } - XCTAssertFalse(hasReminderAfter, "Reminder should be optimistically deleted locally") - } - - func test_deleteReminder_rollsBackOnFailure() throws { - // Prepare data for the test - let messageId: MessageId = .unique - let cid: ChannelId = .unique - let currentUserId: UserId = .unique - - // Create current user in the database - try database.createCurrentUser(id: currentUserId) - - // Create a channel in the database - try database.createChannel(cid: cid) - - // Create a message with reminder - try database.createMessage(id: messageId, cid: cid, text: "Test message") - let messageDTO = try XCTUnwrap(database.viewContext.message(id: messageId)) - - try database.writeSynchronously { session in - try session.saveReminder( - payload: .init( - channelCid: cid, - messageId: messageId, - remindAt: .unique, - createdAt: .unique, - updatedAt: .unique - ), - cache: nil - ) - } - - // Store original reminder values for later comparison - var originalRemindAt: Date? - var originalCreatedAt: Date? - var originalUpdatedAt: Date? - - try database.writeSynchronously { session in - guard let reminder = session.message(id: messageId)?.reminder else { return } - originalRemindAt = reminder.remindAt?.bridgeDate - originalCreatedAt = reminder.createdAt.bridgeDate - originalUpdatedAt = reminder.updatedAt.bridgeDate - } - - // Simulate `deleteReminder` call - let exp = expectation(description: "completion is called") - messageUpdater.deleteReminder( - messageId: messageId, - cid: cid - ) { _ in - exp.fulfill() - } - - // Verify reminder was optimistically deleted - var hasReminderAfterDelete = true - try database.writeSynchronously { session in - hasReminderAfterDelete = session.message(id: messageId)?.reminder != nil - } - XCTAssertFalse(hasReminderAfterDelete, "Reminder should be optimistically deleted") - - // Simulate API failure - apiClient.test_simulateResponse(Result.failure(TestError())) - - wait(for: [exp], timeout: defaultTimeout) - - // Assert reminder was restored with original values - var restoredRemindAt: Date? - var restoredCreatedAt: Date? - var restoredUpdatedAt: Date? - - try database.writeSynchronously { session in - guard let reminder = session.message(id: messageId)?.reminder else { - XCTFail("Reminder should be restored after API failure") - return - } - - restoredRemindAt = reminder.remindAt?.bridgeDate - restoredCreatedAt = reminder.createdAt.bridgeDate - restoredUpdatedAt = reminder.updatedAt.bridgeDate - } - - XCTAssertNearlySameDate(restoredRemindAt, originalRemindAt) - XCTAssertNearlySameDate(restoredCreatedAt, originalCreatedAt) - XCTAssertNearlySameDate(restoredUpdatedAt, originalUpdatedAt) - } } // MARK: - Helpers From aa19fb7436faf67d8af46a382be16a3e3227a1c9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Mar 2025 16:01:05 +0000 Subject: [PATCH 19/42] Handle Reminder Events --- Sources/StreamChat/ChatClientFactory.swift | 1 + .../Database/DTOs/MessageReminderDTO.swift | 5 +- .../ReminderUpdaterMiddleware.swift | 42 ++++ .../WebSocketClient/Events/EventPayload.swift | 7 +- .../WebSocketClient/Events/EventType.swift | 20 +- .../Events/ReminderEvents.swift | 201 +++++++++++++++++ StreamChat.xcodeproj/project.pbxproj | 44 ++++ .../Events/Reminder/ReminderCreated.json | 104 +++++++++ .../Events/Reminder/ReminderDeleted.json | 14 ++ .../JSONs/Events/Reminder/ReminderDue.json | 14 ++ .../Events/Reminder/ReminderUpdated.json | 14 ++ .../ReminderUpdaterMiddleware_Tests.swift | 212 ++++++++++++++++++ .../Events/ReminderEvents_Tests.swift | 177 +++++++++++++++ 13 files changed, 851 insertions(+), 4 deletions(-) create mode 100644 Sources/StreamChat/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware.swift create mode 100644 Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift create mode 100644 TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderCreated.json create mode 100644 TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDeleted.json create mode 100644 TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDue.json create mode 100644 TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderUpdated.json create mode 100644 Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift create mode 100644 Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift index 47634239795..7c2697d129c 100644 --- a/Sources/StreamChat/ChatClientFactory.swift +++ b/Sources/StreamChat/ChatClientFactory.swift @@ -126,6 +126,7 @@ class ChatClientFactory { ), ThreadUpdaterMiddleware(), DraftUpdaterMiddleware(), + ReminderUpdaterMiddleware(), UserTypingStateUpdaterMiddleware(), ChannelTruncatedEventMiddleware(), MemberEventMiddleware(), diff --git a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift index f90a276906c..09a72316d18 100644 --- a/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift @@ -144,11 +144,12 @@ extension NSManagedObjectContext: ReminderDatabaseSession { /// Deletes a reminder for the specified message ID. func deleteReminder(messageId: MessageId) { - guard let reminderDTO = MessageReminderDTO.load(messageId: messageId, context: self) else { + let message = message(id: messageId) + guard let reminderDTO = message?.reminder else { return } - delete(reminderDTO) + message?.reminder = nil } } diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware.swift new file mode 100644 index 00000000000..1091afefca8 --- /dev/null +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware.swift @@ -0,0 +1,42 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +struct ReminderUpdaterMiddleware: EventMiddleware { + func handle(event: Event, session: DatabaseSession) -> Event? { + switch event { + case let event as ReminderCreatedEventDTO: + guard let reminder = event.payload.reminder else { break } + do { + try session.saveReminder(payload: reminder, cache: nil) + } catch { + log.error("Failed to save reminder: \(error)") + } + + case let event as ReminderUpdatedEventDTO: + guard let reminder = event.payload.reminder else { break } + do { + try session.saveReminder(payload: reminder, cache: nil) + } catch { + log.error("Failed to update reminder: \(error)") + } + + case let event as ReminderDueNotificationEventDTO: + guard let reminder = event.payload.reminder else { break } + do { + try session.saveReminder(payload: reminder, cache: nil) + } catch { + log.error("Failed to update reminder in due notification: \(error)") + } + + case let event as ReminderDeletedEventDTO: + let messageId = event.messageId + session.deleteReminder(messageId: messageId) + default: + break + } + return event + } +} diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index c760f6358d4..689d6e61952 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -40,6 +40,7 @@ class EventPayload: Decodable { case messageId = "message_id" case aiMessage = "ai_message" case draft + case reminder } let eventType: EventType @@ -77,6 +78,7 @@ class EventPayload: Decodable { let messageId: String? let aiMessage: String? let draft: DraftPayload? + let reminder: ReminderPayload? init( eventType: EventType, @@ -109,7 +111,8 @@ class EventPayload: Decodable { aiState: String? = nil, messageId: String? = nil, aiMessage: String? = nil, - draft: DraftPayload? = nil + draft: DraftPayload? = nil, + reminder: ReminderPayload? = nil ) { self.eventType = eventType self.connectionId = connectionId @@ -142,6 +145,7 @@ class EventPayload: Decodable { self.messageId = messageId self.aiMessage = aiMessage self.draft = draft + self.reminder = reminder } required init(from decoder: Decoder) throws { @@ -179,6 +183,7 @@ class EventPayload: Decodable { messageId = try container.decodeIfPresent(String.self, forKey: .messageId) aiMessage = try container.decodeIfPresent(String.self, forKey: .aiMessage) draft = try container.decodeIfPresent(DraftPayload.self, forKey: .draft) + reminder = try container.decodeIfPresent(ReminderPayload.self, forKey: .reminder) } func event() throws -> Event { diff --git a/Sources/StreamChat/WebSocketClient/Events/EventType.swift b/Sources/StreamChat/WebSocketClient/Events/EventType.swift index 1268e2b955e..b60029a789c 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventType.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventType.swift @@ -155,13 +155,27 @@ public extension EventType { // When an AI typing indicator has been stopped. static let aiTypingIndicatorStop: Self = "ai_indicator.stop" - // MARK: Drafts + // MARK: - Drafts /// When a draft was updated. static let draftUpdated: Self = "draft.updated" /// When a draft was deleted. static let draftDeleted: Self = "draft.deleted" + + // MARK: - Reminders + + /// When a reminder was created. + static let reminderCreated: Self = "reminder.created" + + /// When a reminder was updated. + static let reminderUpdated: Self = "reminder.updated" + + /// When a reminder was deleted. + static let reminderDeleted: Self = "reminder.deleted" + + /// When a reminder is due. + static let notificationReminderDue: Self = "notification.reminder_due" } extension EventType { @@ -232,6 +246,10 @@ extension EventType { case .aiTypingIndicatorStop: return try AIIndicatorStopEventDTO(from: response) case .draftUpdated: return try DraftUpdatedEventDTO(from: response) case .draftDeleted: return try DraftDeletedEventDTO(from: response) + case .reminderCreated: return try ReminderCreatedEventDTO(from: response) + case .reminderUpdated: return try ReminderUpdatedEventDTO(from: response) + case .reminderDeleted: return try ReminderDeletedEventDTO(from: response) + case .notificationReminderDue: return try ReminderDueNotificationEventDTO(from: response) default: if response.cid == nil { throw ClientError.UnknownUserEvent(response.eventType) diff --git a/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift new file mode 100644 index 00000000000..2c8ee81a09b --- /dev/null +++ b/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift @@ -0,0 +1,201 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Triggered when a message reminder is created. +public class ReminderCreatedEvent: Event { + /// The message ID associated with the reminder. + public let messageId: MessageId + + /// The reminder that was created. + public let reminder: MessageReminder + + /// The channel identifier where the reminder was created. + public var cid: ChannelId { reminder.channel.cid } + + /// The event timestamp. + public let createdAt: Date + + init(messageId: MessageId, reminder: MessageReminder, createdAt: Date) { + self.messageId = messageId + self.reminder = reminder + self.createdAt = createdAt + } +} + +class ReminderCreatedEventDTO: EventDTO { + let messageId: MessageId + let reminder: ReminderPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + messageId = try response.value(at: \.messageId) + reminder = try response.value(at: \.reminder) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: any DatabaseSession) -> Event? { + guard + let reminderDTO = try? session.saveReminder(payload: reminder, cache: nil), + let reminderModel = try? reminderDTO.asModel() + else { return nil } + + return ReminderCreatedEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} + +/// Triggered when a message reminder is updated. +public class ReminderUpdatedEvent: Event { + /// The message ID associated with the reminder. + public let messageId: MessageId + + /// The reminder that was updated. + public let reminder: MessageReminder + + /// The channel identifier where the reminder was updated. + public var cid: ChannelId { reminder.channel.cid } + + /// The event timestamp. + public let createdAt: Date + + init(messageId: MessageId, reminder: MessageReminder, createdAt: Date) { + self.messageId = messageId + self.reminder = reminder + self.createdAt = createdAt + } +} + +class ReminderUpdatedEventDTO: EventDTO { + let messageId: MessageId + let reminder: ReminderPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + messageId = try response.value(at: \.messageId) + reminder = try response.value(at: \.reminder) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: any DatabaseSession) -> Event? { + guard + let reminderDTO = try? session.saveReminder(payload: reminder, cache: nil), + let reminderModel = try? reminderDTO.asModel() + else { return nil } + + return ReminderUpdatedEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} + +/// Triggered when a message reminder is deleted. +public class ReminderDeletedEvent: Event { + /// The message ID associated with the reminder. + public let messageId: MessageId + + /// The reminder information before deletion. + public let reminder: MessageReminder + + /// The channel identifier where the reminder was deleted. + public var cid: ChannelId { reminder.channel.cid } + + /// The event timestamp. + public let createdAt: Date + + init(messageId: MessageId, reminder: MessageReminder, createdAt: Date) { + self.messageId = messageId + self.reminder = reminder + self.createdAt = createdAt + } +} + +class ReminderDeletedEventDTO: EventDTO { + let messageId: MessageId + let reminder: ReminderPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + messageId = try response.value(at: \.messageId) + reminder = try response.value(at: \.reminder) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: any DatabaseSession) -> Event? { + // For deletion events, we need to construct the reminder model before deleting it + guard + let reminderDTO = try? session.saveReminder(payload: reminder, cache: nil), + let reminderModel = try? reminderDTO.asModel() + else { return nil } + + // Delete the reminder from the database + session.deleteReminder(messageId: messageId) + + return ReminderDeletedEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} + +/// Triggered when a reminder is due and a notification should be shown. +public class ReminderDueEvent: Event { + /// The message ID associated with the reminder. + public let messageId: MessageId + + /// The reminder that is due. + public let reminder: MessageReminder + + /// The channel identifier where the reminder is due. + public var cid: ChannelId { reminder.channel.cid } + + /// The event timestamp. + public let createdAt: Date + + init(messageId: MessageId, reminder: MessageReminder, createdAt: Date) { + self.messageId = messageId + self.reminder = reminder + self.createdAt = createdAt + } +} + +class ReminderDueNotificationEventDTO: EventDTO { + let messageId: MessageId + let reminder: ReminderPayload + let createdAt: Date + let payload: EventPayload + + init(from response: EventPayload) throws { + messageId = try response.value(at: \.messageId) + reminder = try response.value(at: \.reminder) + createdAt = try response.value(at: \.createdAt) + payload = response + } + + func toDomainEvent(session: any DatabaseSession) -> Event? { + guard + let reminderDTO = try? session.saveReminder(payload: reminder, cache: nil), + let reminderModel = try? reminderDTO.asModel() + else { return nil } + + return ReminderDueEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b67cf63cc1c..b6522872f56 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1699,6 +1699,16 @@ ADB8B8F92D8B8A0C00549C95 /* RemindersRepository_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */; }; ADB8B8FB2D8B904D00549C95 /* MessageController+Reminders_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */; }; ADB8B8FD2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */; }; + ADB8B9022D8C701000549C95 /* ReminderUpdated.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9012D8C700800549C95 /* ReminderUpdated.json */; }; + ADB8B9042D8C701500549C95 /* ReminderCreated.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9032D8C701500549C95 /* ReminderCreated.json */; }; + ADB8B9062D8C702A00549C95 /* ReminderDeleted.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9052D8C701F00549C95 /* ReminderDeleted.json */; }; + ADB8B9082D8C703300549C95 /* ReminderDue.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9072D8C702E00549C95 /* ReminderDue.json */; }; + ADB8B90A2D8C756600549C95 /* ReminderEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B9092D8C756600549C95 /* ReminderEvents.swift */; }; + ADB8B90B2D8C756600549C95 /* ReminderEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B9092D8C756600549C95 /* ReminderEvents.swift */; }; + ADB8B90D2D8C784500549C95 /* ReminderEvents_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B90C2D8C784500549C95 /* ReminderEvents_Tests.swift */; }; + ADB8B90F2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B90E2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift */; }; + ADB8B9102D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B90E2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift */; }; + ADB8B9122D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B9112D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift */; }; ADB951A1291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */; }; ADB951AB291C1DE400800554 /* AttachmentUploader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */; }; @@ -4423,6 +4433,14 @@ ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersRepository_Mock.swift; sourceTree = ""; }; ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageController+Reminders_Tests.swift"; sourceTree = ""; }; ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUserController+Reminders_Tests.swift"; sourceTree = ""; }; + ADB8B9012D8C700800549C95 /* ReminderUpdated.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderUpdated.json; sourceTree = ""; }; + ADB8B9032D8C701500549C95 /* ReminderCreated.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderCreated.json; sourceTree = ""; }; + ADB8B9052D8C701F00549C95 /* ReminderDeleted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderDeleted.json; sourceTree = ""; }; + ADB8B9072D8C702E00549C95 /* ReminderDue.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderDue.json; sourceTree = ""; }; + ADB8B9092D8C756600549C95 /* ReminderEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderEvents.swift; sourceTree = ""; }; + ADB8B90C2D8C784500549C95 /* ReminderEvents_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderEvents_Tests.swift; sourceTree = ""; }; + ADB8B90E2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderUpdaterMiddleware.swift; sourceTree = ""; }; + ADB8B9112D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderUpdaterMiddleware_Tests.swift; sourceTree = ""; }; ADB951A0291BD7CC00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; ADB951A8291C1DDC00800554 /* AttachmentUploader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader_Spy.swift; sourceTree = ""; }; ADB951AC291C22DB00800554 /* UploadedAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedAttachment.swift; sourceTree = ""; }; @@ -5562,6 +5580,7 @@ 79280F402484F4DD00CDEB89 /* Events */ = { isa = PBXGroup; children = ( + ADB8B9092D8C756600549C95 /* ReminderEvents.swift */, 79280F46248515FA00CDEB89 /* ChannelEvents.swift */, 79280F4A248523C000CDEB89 /* ConnectionEvents.swift */, 79280F412484F4EC00CDEB89 /* Event.swift */, @@ -5769,6 +5788,7 @@ 796610B7248E64EC00761629 /* EventMiddlewares */ = { isa = PBXGroup; children = ( + ADB8B90E2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift */, AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */, 79896D63250A62EE00BA8F1C /* ChannelReadUpdaterMiddleware.swift */, AD9632E02C0A43630073B814 /* ThreadUpdaterMiddleware.swift */, @@ -6627,6 +6647,7 @@ 8A62705F24BE31B20040BFD6 /* Events */ = { isa = PBXGroup; children = ( + ADB8B8FE2D8C6FED00549C95 /* Reminder */, AD545E8A2D5D8095008FD399 /* Draft */, 84E46A332CFA1B73000CBDDE /* AIIndicator */, ADE57B802C3C5C4600DD6B88 /* Thread */, @@ -7021,6 +7042,7 @@ A364D08D27D0BD8E0029857A /* EventMiddlewares */ = { isa = PBXGroup; children = ( + ADB8B9112D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift */, AD545E8D2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift */, 79896D65250A6D1500BA8F1C /* ChannelReadUpdaterMiddleware_Tests.swift */, AD7BE16C2C20CC02000A5756 /* ThreadUpdaterMiddlware_Tests.swift */, @@ -7042,6 +7064,7 @@ A364D08E27D0BDB20029857A /* Events */ = { isa = PBXGroup; children = ( + ADB8B90C2D8C784500549C95 /* ReminderEvents_Tests.swift */, AD545E862D5D805A008FD399 /* DraftEvents_Tests.swift */, 8A62706B24BF3DBC0040BFD6 /* ChannelEvents_Tests.swift */, 84A1D2EF26AB10DB00014712 /* EventDecoder_Tests.swift */, @@ -8832,6 +8855,17 @@ path = QuotedChatMessageView; sourceTree = ""; }; + ADB8B8FE2D8C6FED00549C95 /* Reminder */ = { + isa = PBXGroup; + children = ( + ADB8B9072D8C702E00549C95 /* ReminderDue.json */, + ADB8B9052D8C701F00549C95 /* ReminderDeleted.json */, + ADB8B9012D8C700800549C95 /* ReminderUpdated.json */, + ADB8B9032D8C701500549C95 /* ReminderCreated.json */, + ); + path = Reminder; + sourceTree = ""; + }; ADB951A3291BD7F700800554 /* CDNClient */ = { isa = PBXGroup; children = ( @@ -10269,6 +10303,7 @@ A311B41227E8B9B900CFCF6D /* UserStopTyping.json in Resources */, A311B3F427E8B99800CFCF6D /* ChannelUpdated.json in Resources */, A311B42627E8B9CE00CFCF6D /* MessageReactionPayload+DefaultExtraData.json in Resources */, + ADB8B9082D8C703300549C95 /* ReminderDue.json in Resources */, A311B3FC27E8B9A800CFCF6D /* MessageUpdated.json in Resources */, C1616DB228DC4D7F00FF993B /* UserGloballyBanned.json in Resources */, A311B3DC27E8B98C00CFCF6D /* MessagePayload.json in Resources */, @@ -10298,6 +10333,7 @@ A311B3DA27E8B98C00CFCF6D /* CurrentUser.json in Resources */, A311B3D527E8B98C00CFCF6D /* ChannelPayload.json in Resources */, A368E71627F33E16009063C1 /* MissingEventsPayload-IncompleteChannel.json in Resources */, + ADB8B9022D8C701000549C95 /* ReminderUpdated.json in Resources */, A311B40B27E8B9AD00CFCF6D /* NotificationMessageNew.json in Resources */, A311B41027E8B9B300CFCF6D /* ReactionNew.json in Resources */, C1616DB128DC4D7F00FF993B /* UserGloballyUnbanned.json in Resources */, @@ -10319,6 +10355,7 @@ A311B3EE27E8B99800CFCF6D /* ChannelTruncated.json in Resources */, A311B41327E8B9B900CFCF6D /* UserBanned.json in Resources */, A311B41527E8B9B900CFCF6D /* UserStartTyping.json in Resources */, + ADB8B9042D8C701500549C95 /* ReminderCreated.json in Resources */, A311B42A27E8B9D800CFCF6D /* UserUpdateResponse+MissingUser.json in Resources */, A311B3D827E8B98C00CFCF6D /* Devices.json in Resources */, A311B3D627E8B98C00CFCF6D /* Member.json in Resources */, @@ -10339,6 +10376,7 @@ A311B3D927E8B98C00CFCF6D /* ChannelPayloadWithCustom.json in Resources */, AD545E6D2D565316008FD399 /* DraftMessage.json in Resources */, A311B42027E8B9C400CFCF6D /* FlagUserPayload+NoExtraData.json in Resources */, + ADB8B9062D8C702A00549C95 /* ReminderDeleted.json in Resources */, ADF3EEF62C00FC7B00DB36D6 /* NotificationMarkUnread+MissingFields.json in Resources */, A311B3E627E8B99200CFCF6D /* AttachmentPayloadImage.json in Resources */, A311B3FA27E8B9A800CFCF6D /* MessageDeletedHard.json in Resources */, @@ -11518,6 +11556,7 @@ 8413D2F22BDDAAEE005ADA4E /* PollVoteListController+Combine.swift in Sources */, 8A0CC9F124C606EF00705CF9 /* ReactionEvents.swift in Sources */, C143788D27BBEBB700E23965 /* OfflineRequestsRepository.swift in Sources */, + ADB8B90B2D8C756600549C95 /* ReminderEvents.swift in Sources */, 79877A0F2498E4BC00015F8B /* ChannelId.swift in Sources */, AD0CC0312BDC1964005E2C66 /* ReactionListQueryDTO.swift in Sources */, 882C5760252C7CC400E60C44 /* ChannelMemberListQueryDTO.swift in Sources */, @@ -11586,6 +11625,7 @@ 799C9479247E3DEA001F1104 /* StreamChatModel.xcdatamodeld in Sources */, 888E8C55252B525300195E03 /* MemberController.swift in Sources */, 79877A1C2498E4EE00015F8B /* Endpoint.swift in Sources */, + ADB8B90F2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift in Sources */, 882C5759252C794900E60C44 /* MemberEndpoints.swift in Sources */, DA640FC12535CFA100D32944 /* ChannelMemberListSortingKey.swift in Sources */, 7900452625374CA20096ECA1 /* User+SwiftUI.swift in Sources */, @@ -11870,6 +11910,7 @@ 791C0B6324EEBDF40013CA2F /* MessageSender_Tests.swift in Sources */, 797EEA4824FFB4C200C81203 /* DataStore_Tests.swift in Sources */, 882C574E252C76A400E60C44 /* ChannelMemberListPayload_Tests.swift in Sources */, + ADB8B9122D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift in Sources */, 4042966929FA6B4B0089126D /* StreamAudioRecorder_Tests.swift in Sources */, 4F14F1282BBD2D8700B1074E /* ChannelList_Tests.swift in Sources */, 88D85D9D252F16A300AE1030 /* MemberController+SwiftUI_Tests.swift in Sources */, @@ -12060,6 +12101,7 @@ 79CD959624F9414700E87377 /* ChannelListController+SwiftUI_Tests.swift in Sources */, DAE566F02500140300E39431 /* ChannelController+SwiftUI_Tests.swift in Sources */, AD45334E25D153E500CD9D47 /* ConnectionController+Combine_Tests.swift in Sources */, + ADB8B90D2D8C784500549C95 /* ReminderEvents_Tests.swift in Sources */, DAD5C8372502842C0045117A /* CurrentUserController+Combine_Tests.swift in Sources */, 7952B3B524D45DA300AC53D4 /* ChannelUpdater_Tests.swift in Sources */, 40B345F629C46AE500B96027 /* AudioPlaybackContext_Tests.swift in Sources */, @@ -12627,6 +12669,7 @@ C121E8C0274544B100023E4C /* EventsController+Combine.swift in Sources */, C121E8C1274544B100023E4C /* EventsController+SwiftUI.swift in Sources */, C121E8C2274544B100023E4C /* ChannelEventsController.swift in Sources */, + ADB8B90A2D8C756600549C95 /* ReminderEvents.swift in Sources */, C121E8C3274544B100023E4C /* ListChange.swift in Sources */, C15C8839286C7BF300E6A72C /* BackgroundListDatabaseObserver.swift in Sources */, 848849B62CEE01070010E7CA /* AITypingEvents.swift in Sources */, @@ -12651,6 +12694,7 @@ C121E8CD274544B100023E4C /* Pagination.swift in Sources */, C121E8CE274544B100023E4C /* Sorting.swift in Sources */, C121E8CF274544B100023E4C /* ChannelListSortingKey.swift in Sources */, + ADB8B9102D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift in Sources */, AD37D7C82BC98A4400800D8C /* ThreadParticipantDTO.swift in Sources */, C121E8D0274544B100023E4C /* UserListSortingKey.swift in Sources */, C121E8D1274544B100023E4C /* ChannelMemberListSortingKey.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderCreated.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderCreated.json new file mode 100644 index 00000000000..0904eb72b21 --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderCreated.json @@ -0,0 +1,104 @@ +{ + "created_at": "2025-03-20T15:50:09.884009Z", + "message_id": "f7af18f2-0a46-431d-8901-19c105de7f0a", + "reminder": { + "channel": { + "auto_translation_language": "", + "cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "config": { + "automod": "disabled", + "automod_behavior": "flag", + "commands": [], + "connect_events": true, + "created_at": "2025-03-14T09:56:35.247111552Z", + "custom_events": true, + "mark_messages_pending": false, + "max_message_length": 5000, + "message_retention": "infinite", + "mutes": true, + "name": "messaging", + "polls": false, + "push_notifications": true, + "quotes": true, + "reactions": true, + "read_events": true, + "reminders": false, + "replies": true, + "search": true, + "skip_last_msg_update_for_system_msgs": false, + "typing_events": true, + "updated_at": "2025-03-14T09:56:35.247111667Z", + "uploads": true, + "url_enrichment": true, + "user_message_reminders": true + }, + "created_at": "2024-07-10T11:47:43.591964Z", + "created_by": { + "banned": false, + "birthland": "Corellia", + "created_at": "2024-07-09T10:25:12.255599Z", + "id": "han_solo", + "image": "https://vignette.wikia.nocookie.net/starwars/images/e/e2/TFAHanSolo.png", + "last_active": "2025-03-20T15:43:24.605958109Z", + "last_engaged_at": "2025-03-20T00:24:49.453063Z", + "name": "Han Solo", + "online": true, + "role": "user", + "updated_at": "2025-02-25T13:47:31.92961Z" + }, + "disabled": false, + "frozen": false, + "id": "!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "last_message_at": "2025-03-19T16:22:51.617418Z", + "member_count": 2, + "type": "messaging", + "updated_at": "2024-07-10T11:47:43.591964Z" + }, + "channel_cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "created_at": "2025-03-20T15:50:09.878366305Z", + "message": { + "attachments": [], + "cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "created_at": "2025-03-19T16:22:51.617418Z", + "deleted_reply_count": 0, + "html": "

aaa

\n", + "id": "f7af18f2-0a46-431d-8901-19c105de7f0a", + "latest_reactions": [], + "mentioned_users": [], + "own_reactions": [], + "pin_expires": null, + "pinned": false, + "pinned_at": null, + "pinned_by": null, + "reaction_counts": {}, + "reaction_groups": null, + "reaction_scores": {}, + "reply_count": 0, + "restricted_visibility": [], + "shadowed": false, + "silent": false, + "text": "aaa", + "type": "regular", + "updated_at": "2025-03-19T16:22:51.617418Z", + "user": { + "banned": false, + "birthland": "Polis Massa", + "created_at": "2024-07-05T14:04:50.791858Z", + "id": "leia_organa", + "image": "https://vignette.wikia.nocookie.net/starwars/images/f/fc/Leia_Organa_TLJ.png", + "last_active": "2025-03-19T16:25:45.642205Z", + "last_engaged_at": "2025-03-19T16:22:46.182706Z", + "name": "Leia Organa", + "online": false, + "role": "user", + "updated_at": "2024-07-05T14:04:50.82618Z" + } + }, + "message_id": "f7af18f2-0a46-431d-8901-19c105de7f0a", + "remind_at": null, + "updated_at": "2025-03-20T15:50:09.878366305Z", + "user_id": "han_solo" + }, + "type": "reminder.created", + "user_id": "han_solo" +} diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDeleted.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDeleted.json new file mode 100644 index 00000000000..98c0738f3f9 --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDeleted.json @@ -0,0 +1,14 @@ +{ + "created_at": "2025-03-20T15:49:25.236274751Z", + "message_id": "477172a9-a59b-48dc-94a3-9aec4dc181bb", + "reminder": { + "channel_cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "created_at": "2025-03-19T17:37:14.737404Z", + "message_id": "477172a9-a59b-48dc-94a3-9aec4dc181bb", + "remind_at": "2025-03-20T15:50:58.1Z", + "updated_at": "2025-03-20T15:48:58.664435Z", + "user_id": "han_solo" + }, + "type": "reminder.deleted", + "user_id": "han_solo" +} diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDue.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDue.json new file mode 100644 index 00000000000..01be2033c82 --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderDue.json @@ -0,0 +1,14 @@ +{ + "created_at": "2025-03-20T15:48:58.670372602Z", + "message_id": "477172a9-a59b-48dc-94a3-9aec4dc181bb", + "reminder": { + "channel_cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "created_at": "2025-03-19T17:37:14.737404Z", + "message_id": "477172a9-a59b-48dc-94a3-9aec4dc181bb", + "remind_at": "2025-03-20T15:50:58.1Z", + "updated_at": "2025-03-20T15:48:58.664435Z", + "user_id": "han_solo" + }, + "type": "notification.reminder_due", + "user_id": "han_solo" +} diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderUpdated.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderUpdated.json new file mode 100644 index 00000000000..e72f3d6c3e9 --- /dev/null +++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Events/Reminder/ReminderUpdated.json @@ -0,0 +1,14 @@ +{ + "created_at": "2025-03-20T15:48:58.670372602Z", + "message_id": "477172a9-a59b-48dc-94a3-9aec4dc181bb", + "reminder": { + "channel_cid": "messaging:!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E", + "created_at": "2025-03-19T17:37:14.737404Z", + "message_id": "477172a9-a59b-48dc-94a3-9aec4dc181bb", + "remind_at": "2025-03-20T15:50:58.1Z", + "updated_at": "2025-03-20T15:48:58.664435Z", + "user_id": "han_solo" + }, + "type": "reminder.updated", + "user_id": "han_solo" +} diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift new file mode 100644 index 00000000000..3389367ee74 --- /dev/null +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift @@ -0,0 +1,212 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReminderUpdaterMiddleware_Tests: XCTestCase { + var database: DatabaseContainer_Spy! + var middleware: ReminderUpdaterMiddleware! + + override func setUp() { + super.setUp() + database = DatabaseContainer_Spy(kind: .inMemory) + middleware = ReminderUpdaterMiddleware() + } + + override func tearDown() { + middleware = nil + database = nil + super.tearDown() + } + + func test_reminderCreatedEvent_savesReminder() throws { + // Setup + let messageId = "test-message-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + let reminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: nil, + createdAt: Date(), + updatedAt: Date() + ) + + let eventPayload = EventPayload( + eventType: .reminderCreated, + createdAt: Date(), + messageId: messageId, + reminder: reminderPayload + ) + + let event = try ReminderCreatedEventDTO(from: eventPayload) + + // Save required data for reminder to reference + try database.writeSynchronously { session in + try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + try session.saveMessage( + payload: .dummy(messageId: messageId, authorUserId: "test-user"), + for: cid, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: nil + ) + } + + // Execute + _ = middleware.handle(event: event, session: database.viewContext) + + // Assert + let reminder = database.viewContext.message(id: messageId)?.reminder + XCTAssertNotNil(reminder, "Reminder should be saved") + XCTAssertEqual(reminder?.id, messageId, "Reminder ID should match message ID") + } + + func test_reminderUpdatedEvent_updatesReminder() throws { + // Setup + let messageId = "test-message-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + let initialDate = Date().addingTimeInterval(-3600) // 1 hour ago + let updatedDate = Date() // now + + // First create the reminder + let initialReminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: initialDate, + createdAt: initialDate, + updatedAt: initialDate + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + try session.saveMessage( + payload: .dummy(messageId: messageId, authorUserId: "test-user"), + for: cid, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: nil + ) + try session.saveReminder(payload: initialReminderPayload, cache: nil) + } + + // Create update payload + let updatedReminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: updatedDate, + createdAt: initialDate, + updatedAt: updatedDate + ) + + let eventPayload = EventPayload( + eventType: .reminderUpdated, + createdAt: Date(), + messageId: messageId, + reminder: updatedReminderPayload + ) + + let event = try ReminderUpdatedEventDTO(from: eventPayload) + + // Execute + _ = middleware.handle(event: event, session: database.viewContext) + + // Assert + let reminder = database.viewContext.message(id: messageId)?.reminder + XCTAssertNotNil(reminder, "Reminder should exist") + XCTAssertEqual(reminder?.remindAt?.bridgeDate, updatedDate, "Reminder date should be updated") + } + + func test_reminderDueNotificationEvent_updatesReminder() throws { + // Setup + let messageId = "test-message-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + let initialDate = Date().addingTimeInterval(-3600) // 1 hour ago + + // First create the reminder + let initialReminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: initialDate, + createdAt: initialDate, + updatedAt: initialDate + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + try session.saveMessage( + payload: .dummy(messageId: messageId, authorUserId: "test-user"), + for: cid, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: nil + ) + try session.saveReminder(payload: initialReminderPayload, cache: nil) + } + + // Create due notification payload (same as the original in this case) + let eventPayload = EventPayload( + eventType: .notificationReminderDue, + createdAt: Date(), + messageId: messageId, + reminder: initialReminderPayload + ) + + let event = try ReminderDueNotificationEventDTO(from: eventPayload) + + // Execute + _ = middleware.handle(event: event, session: database.viewContext) + + // Assert + let reminder = database.viewContext.message(id: messageId)?.reminder + XCTAssertNotNil(reminder, "Reminder should still exist after due notification") + } + + func test_reminderDeletedEvent_deletesReminder() throws { + // Setup + let messageId = "test-message-id" + let cid = ChannelId(type: .messaging, id: "test-channel") + + // First create the reminder + let reminderPayload = ReminderPayload( + channelCid: cid, + messageId: messageId, + remindAt: Date(), + createdAt: Date(), + updatedAt: Date() + ) + + try database.writeSynchronously { session in + try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + try session.saveMessage( + payload: .dummy(messageId: messageId, authorUserId: "test-user"), + for: cid, + syncOwnReactions: false, + skipDraftUpdate: true, + cache: nil + ) + try session.saveReminder(payload: reminderPayload, cache: nil) + } + + // Verify reminder exists + XCTAssertNotNil(database.viewContext.message(id: messageId)?.reminder, "Reminder should exist before deletion") + + // Create delete event payload + let eventPayload = EventPayload( + eventType: .reminderDeleted, + createdAt: Date(), + messageId: messageId, + reminder: reminderPayload + ) + + let event = try ReminderDeletedEventDTO(from: eventPayload) + + // Execute + _ = middleware.handle(event: event, session: database.viewContext) + + // Assert + XCTAssertNil(database.viewContext.message(id: messageId)?.reminder, "Reminder should be deleted") + } +} diff --git a/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift new file mode 100644 index 00000000000..bb6560d2bb7 --- /dev/null +++ b/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift @@ -0,0 +1,177 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReminderEvents_Tests: XCTestCase { + private let messageId = "477172a9-a59b-48dc-94a3-9aec4dc181bb" + private let cid = ChannelId(type: .messaging, id: "!members-vhPyEGDAjFA4JyC7fxDg3LsMFLGqKhXOKqZM-Y681_E") + + var eventDecoder: EventDecoder! + + override func setUp() { + super.setUp() + eventDecoder = EventDecoder() + } + + override func tearDown() { + super.tearDown() + eventDecoder = nil + } + + // MARK: - ReminderCreatedEvent Tests + + func test_reminderCreatedEvent_decoding() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderCreated") + let event = try eventDecoder.decode(from: json) as? ReminderCreatedEventDTO + + XCTAssertNotNil(event) + XCTAssertEqual(event?.messageId, "f7af18f2-0a46-431d-8901-19c105de7f0a") + XCTAssertEqual(event?.reminder.channelCid, cid) + XCTAssertNil(event?.reminder.remindAt) + XCTAssertEqual(event?.createdAt.description, "2025-03-20 15:50:09 +0000") + } + + func test_reminderCreatedEvent_toDomainEvent() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderCreated") + let event = try eventDecoder.decode(from: json) as? ReminderCreatedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Save required data + let channelId = event?.reminder.channelCid ?? cid + let messageId = event?.messageId ?? "test-message-id" + _ = try session.saveChannel(payload: .dummy(cid: channelId), query: nil, cache: nil) + _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: channelId, cache: nil) + + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderCreatedEvent) + XCTAssertEqual(domainEvent.messageId, "f7af18f2-0a46-431d-8901-19c105de7f0a") + XCTAssertEqual(domainEvent.reminder.id, "f7af18f2-0a46-431d-8901-19c105de7f0a") + XCTAssertEqual(domainEvent.reminder.channel.cid, channelId) + } + + func test_reminderCreatedEvent_toDomainEvent_returnsNilWhenMissingData() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderCreated") + let event = try eventDecoder.decode(from: json) as? ReminderCreatedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Don't save any data to test nil case + XCTAssertNil(event?.toDomainEvent(session: session)) + } + + // MARK: - ReminderUpdatedEvent Tests + + func test_reminderUpdatedEvent_decoding() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderUpdated") + let event = try eventDecoder.decode(from: json) as? ReminderUpdatedEventDTO + + XCTAssertNotNil(event) + XCTAssertEqual(event?.messageId, messageId) + XCTAssertEqual(event?.reminder.channelCid, cid) + XCTAssertEqual(event?.reminder.remindAt?.description, "2025-03-20 15:50:58 +0000") + XCTAssertEqual(event?.createdAt.description, "2025-03-20 15:48:58 +0000") + } + + func test_reminderUpdatedEvent_toDomainEvent() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderUpdated") + let event = try eventDecoder.decode(from: json) as? ReminderUpdatedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Save required data + _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: cid, cache: nil) + + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderUpdatedEvent) + XCTAssertEqual(domainEvent.messageId, messageId) + XCTAssertEqual(domainEvent.reminder.id, messageId) + XCTAssertEqual(domainEvent.reminder.channel.cid, cid) + XCTAssertEqual(domainEvent.reminder.remindAt?.description, "2025-03-20 15:50:58 +0000") + } + + func test_reminderUpdatedEvent_toDomainEvent_returnsNilWhenMissingData() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderUpdated") + let event = try eventDecoder.decode(from: json) as? ReminderUpdatedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Don't save any data to test nil case + XCTAssertNil(event?.toDomainEvent(session: session)) + } + + // MARK: - ReminderDeletedEvent Tests + + func test_reminderDeletedEvent_decoding() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderDeleted") + let event = try eventDecoder.decode(from: json) as? ReminderDeletedEventDTO + + XCTAssertNotNil(event) + XCTAssertEqual(event?.messageId, messageId) + XCTAssertEqual(event?.reminder.channelCid, cid) + XCTAssertEqual(event?.reminder.remindAt?.description, "2025-03-20 15:50:58 +0000") + XCTAssertEqual(event?.createdAt.description, "2025-03-20 15:49:25 +0000") + } + + func test_reminderDeletedEvent_toDomainEvent() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderDeleted") + let event = try eventDecoder.decode(from: json) as? ReminderDeletedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Save required data + _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: cid, cache: nil) + + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderDeletedEvent) + XCTAssertEqual(domainEvent.messageId, messageId) + XCTAssertEqual(domainEvent.reminder.id, messageId) + XCTAssertEqual(domainEvent.reminder.channel.cid, cid) + XCTAssertEqual(domainEvent.reminder.remindAt?.description, "2025-03-20 15:50:58 +0000") + } + + func test_reminderDeletedEvent_toDomainEvent_returnsNilWhenMissingData() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderDeleted") + let event = try eventDecoder.decode(from: json) as? ReminderDeletedEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Don't save any data to test nil case + XCTAssertNil(event?.toDomainEvent(session: session)) + } + + // MARK: - ReminderDueEvent Tests + + func test_reminderDueEvent_decoding() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderDue") + let event = try eventDecoder.decode(from: json) as? ReminderDueNotificationEventDTO + + XCTAssertNotNil(event) + XCTAssertEqual(event?.messageId, messageId) + XCTAssertEqual(event?.reminder.channelCid, cid) + XCTAssertEqual(event?.reminder.remindAt?.description, "2025-03-20 15:50:58 +0000") + XCTAssertEqual(event?.createdAt.description, "2025-03-20 15:48:58 +0000") + } + + func test_reminderDueEvent_toDomainEvent() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderDue") + let event = try eventDecoder.decode(from: json) as? ReminderDueNotificationEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Save required data + _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) + _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: cid, cache: nil) + + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderDueEvent) + XCTAssertEqual(domainEvent.messageId, messageId) + XCTAssertEqual(domainEvent.reminder.id, messageId) + XCTAssertEqual(domainEvent.reminder.channel.cid, cid) + XCTAssertEqual(domainEvent.reminder.remindAt?.description, "2025-03-20 15:50:58 +0000") + } + + func test_reminderDueEvent_toDomainEvent_returnsNilWhenMissingData() throws { + let json = XCTestCase.mockData(fromJSONFile: "ReminderDue") + let event = try eventDecoder.decode(from: json) as? ReminderDueNotificationEventDTO + let session = DatabaseContainer_Spy(kind: .inMemory).viewContext + + // Don't save any data to test nil case + XCTAssertNil(event?.toDomainEvent(session: session)) + } +} From 7950334404684ef8d4ab8970dab4a41029332ff0 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Mar 2025 17:55:38 +0000 Subject: [PATCH 20/42] Fix forgotten hardcoded test in reminder list query --- Sources/StreamChat/Query/MessageReminderListQuery.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StreamChat/Query/MessageReminderListQuery.swift b/Sources/StreamChat/Query/MessageReminderListQuery.swift index 21b4adcd8ac..39afba99572 100644 --- a/Sources/StreamChat/Query/MessageReminderListQuery.swift +++ b/Sources/StreamChat/Query/MessageReminderListQuery.swift @@ -100,7 +100,7 @@ public struct MessageReminderListQuery: Encodable { public init( filter: Filter? = nil, sort: [Sorting] = [.init(key: .remindAt, isAscending: true)], - pageSize: Int = 5, + pageSize: Int = 25, next: String? = nil ) { self.filter = filter From 470c0a35a0325dbd89abcf8ab7b495686afc3423 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Mar 2025 18:19:19 +0000 Subject: [PATCH 21/42] Fix reminder list query tests --- .../Query/MessageReminderListQuery_Tests.swift | 10 ++++++---- .../ReminderUpdaterMiddleware_Tests.swift | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift index 069d5f5bcf5..a1dd35e58b8 100644 --- a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift +++ b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift @@ -36,7 +36,8 @@ final class MessageReminderListQuery_Tests: XCTestCase { } func test_encode_withAllFields() throws { - let filter = Filter.equal(.cid, to: ChannelId.unique) + let cid: ChannelId = .init(type: .messaging, id: "123") + let filter = Filter.equal(.cid, to: cid) let sort = [Sorting(key: .createdAt, isAscending: false)] let query = MessageReminderListQuery( @@ -46,7 +47,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { ) let expectedData: [String: Any] = [ - "filter": ["channel_cid": ["$eq": filter.value]], + "filter": ["channel_cid": ["$eq": cid.rawValue]], "sort": [["field": "created_at", "direction": -1]], "limit": 10 ] @@ -76,7 +77,8 @@ final class MessageReminderListQuery_Tests: XCTestCase { } func test_encode_withoutSort() throws { - let filter = Filter.equal(.cid, to: ChannelId.unique) + let cid: ChannelId = .init(type: .messaging, id: "123") + let filter = Filter.equal(.cid, to: cid) let query = MessageReminderListQuery( filter: filter, @@ -85,7 +87,7 @@ final class MessageReminderListQuery_Tests: XCTestCase { ) let expectedData: [String: Any] = [ - "filter": ["channel_cid": ["$eq": filter.value]], + "filter": ["channel_cid": ["$eq": cid.rawValue]], "limit": 10 ] diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift index 3389367ee74..a2d6b76af7e 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift @@ -116,7 +116,7 @@ final class ReminderUpdaterMiddleware_Tests: XCTestCase { // Assert let reminder = database.viewContext.message(id: messageId)?.reminder XCTAssertNotNil(reminder, "Reminder should exist") - XCTAssertEqual(reminder?.remindAt?.bridgeDate, updatedDate, "Reminder date should be updated") + XCTAssertNearlySameDate(reminder?.remindAt?.bridgeDate, updatedDate) } func test_reminderDueNotificationEvent_updatesReminder() throws { From 0f44197b8af3d5dc0476cf6e771684c342f1840b Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 20 Mar 2025 18:21:52 +0000 Subject: [PATCH 22/42] Add local push notification when reminder is expired --- DemoApp/Screens/DemoAppTabBarController.swift | 50 ++++++++++++++++++- DemoApp/Screens/DemoReminderListVC.swift | 1 + 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index dfd5288fda4..4acf84c477f 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -5,13 +5,20 @@ import StreamChat import StreamChatUI import UIKit +import UserNotifications -class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate { +class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, EventsControllerDelegate { let channelListVC: UIViewController let threadListVC: UIViewController let draftListVC: UIViewController let reminderListVC: UIViewController let currentUserController: CurrentChatUserController + + // Events controller for listening to chat events + private var eventsController: EventsController! + + // User notification center for displaying local notifications + private let notificationCenter = UNUserNotificationCenter.current() init( channelListVC: UIViewController, @@ -54,6 +61,9 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele currentUserController.delegate = self unreadCount = currentUserController.unreadCount + + // Initialize events controller + setupEventsController() // Load reminders with remindAt to update the badge. currentUserController.loadReminders(query: .init( @@ -80,6 +90,44 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele viewControllers = [channelListVC, threadListVC, draftListVC, reminderListVC] } + + // MARK: - Events Controller Setup + + private func setupEventsController() { + // Get the ChatClient instance from the currentUserController + let client = currentUserController.client + + // Initialize the events controller + eventsController = client.eventsController() + + // Set this class as the delegate for events + eventsController.delegate = self + } + + // MARK: - EventsControllerDelegate + + func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { + // Check if the event is a ReminderDueEvent + if let reminderDueEvent = event as? ReminderDueEvent { + // Handle the reminder due event + handleReminderDueEvent(reminderDueEvent) + } + } + + // MARK: - Handle Reminder Due Event + + private func handleReminderDueEvent(_ event: ReminderDueEvent) { + let messageText = event.reminder.message.text + let content = UNMutableNotificationContent() + content.title = "Reminder due" + content.body = messageText + content.sound = .default + + let identifier = "reminder-\(event.messageId)-\(Date().timeIntervalSince1970)" + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + notificationCenter.add(request) + } func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { let unreadCount = didChangeCurrentUserUnreadCount diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index c04fa87acf3..f2c3b12c825 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -335,6 +335,7 @@ class DemoReminderListVC: UIViewController, ThemeProvider { currentUserController.delegate = self if reminders.isEmpty { loadingIndicator.startAnimating() + emptyStateView.isHidden = true } let query = createFilterQuery() From 501d4ca257a3b53434cde9c4c7aaf02d58516bf9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 21 Mar 2025 16:42:25 +0000 Subject: [PATCH 23/42] Fix reminder with empty text in the Reminder List Demo App UI --- DemoApp/Screens/DemoReminderListVC.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index f2c3b12c825..586d7a641b9 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -599,7 +599,12 @@ class DemoReminderCell: UITableViewCell { channelNameLabel.text = "# \(channelName)" } - messageLabel.text = reminder.message.text + if reminder.message.text.isEmpty { + let attachmentType = reminder.message.allAttachments.first?.type.rawValue.capitalized ?? "" + messageLabel.text = "📎 \(attachmentType)" + } else { + messageLabel.text = reminder.message.text + } // Configure based on reminder type if let remindAt = reminder.remindAt { From 5ced89132a774e02d47210feffdde1a699d92062 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 21 Mar 2025 16:57:26 +0000 Subject: [PATCH 24/42] Add Demo App reminders feature flag --- .../AppConfigViewController.swift | 11 +++++++- DemoApp/Screens/DemoAppTabBarController.swift | 28 ++++++++++++------- .../Components/DemoChatMessageActionsVC.swift | 7 +++-- ...DemoChatMessageLayoutOptionsResolver.swift | 2 +- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 1081afb0613..b8977a48d26 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -22,6 +22,8 @@ struct DemoAppConfig { var shouldShowConnectionBanner: Bool /// A Boolean value to define if the premium member feature is enabled. This is to test custom member data. var isPremiumMemberFeatureEnabled: Bool + /// A Boolean value to define if the reminders feature is enabled. + var isRemindersEnabled: Bool /// The details to generate expirable tokens in the demo app. struct TokenRefreshDetails { @@ -50,7 +52,8 @@ class AppConfig { isLocationAttachmentsEnabled: false, tokenRefreshDetails: nil, shouldShowConnectionBanner: false, - isPremiumMemberFeatureEnabled: false + isPremiumMemberFeatureEnabled: false, + isRemindersEnabled: false ) if StreamRuntimeCheck.isStreamInternalConfiguration { @@ -61,6 +64,7 @@ class AppConfig { demoAppConfig.isHardDeleteEnabled = true demoAppConfig.shouldShowConnectionBanner = true demoAppConfig.isPremiumMemberFeatureEnabled = true + demoAppConfig.isRemindersEnabled = true StreamRuntimeCheck.assertionsEnabled = true } } @@ -173,6 +177,7 @@ class AppConfigViewController: UITableViewController { case tokenRefreshDetails case shouldShowConnectionBanner case isPremiumMemberFeatureEnabled + case isRemindersEnabled } enum ComponentsConfigOption: String, CaseIterable { @@ -338,6 +343,10 @@ class AppConfigViewController: UITableViewController { cell.accessoryView = makeSwitchButton(demoAppConfig.isPremiumMemberFeatureEnabled) { [weak self] newValue in self?.demoAppConfig.isPremiumMemberFeatureEnabled = newValue } + case .isRemindersEnabled: + cell.accessoryView = makeSwitchButton(demoAppConfig.isRemindersEnabled) { [weak self] newValue in + self?.demoAppConfig.isRemindersEnabled = newValue + } } } diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 4acf84c477f..bad0186a9cc 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -65,11 +65,10 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele // Initialize events controller setupEventsController() - // Load reminders with remindAt to update the badge. - currentUserController.loadReminders(query: .init( - filter: .withRemindAt, - pageSize: 50 - )) + // Update reminders badge if the feature is enabled. + if AppConfig.shared.demoAppConfig.isRemindersEnabled { + updateRemindersBadge() + } tabBar.backgroundColor = Appearance.default.colorPalette.background tabBar.isTranslucent = true @@ -88,7 +87,12 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele reminderListVC.tabBarItem.title = "Reminders" reminderListVC.tabBarItem.image = UIImage(systemName: "bell") - viewControllers = [channelListVC, threadListVC, draftListVC, reminderListVC] + // Only show reminders tab if the feature is enabled + if AppConfig.shared.demoAppConfig.isRemindersEnabled { + viewControllers = [channelListVC, threadListVC, draftListVC, reminderListVC] + } else { + viewControllers = [channelListVC, threadListVC, draftListVC] + } } // MARK: - Events Controller Setup @@ -107,9 +111,8 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele // MARK: - EventsControllerDelegate func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { - // Check if the event is a ReminderDueEvent - if let reminderDueEvent = event as? ReminderDueEvent { - // Handle the reminder due event + if AppConfig.shared.demoAppConfig.isRemindersEnabled, + let reminderDueEvent = event as? ReminderDueEvent { handleReminderDueEvent(reminderDueEvent) } } @@ -140,6 +143,11 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele _ controller: CurrentChatUserController, didChangeMessageReminders messageReminders: [MessageReminder] ) { - reminderListVC.tabBarItem.badgeValue = messageReminders.isEmpty ? nil : "\(messageReminders.count)" + updateRemindersBadge() + } + + private func updateRemindersBadge() { + let reminders = currentUserController.messageReminders + reminderListVC.tabBarItem.badgeValue = reminders.isEmpty ? nil : "\(reminders.count)" } } diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index fc3610522ce..e60ffde0278 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift @@ -20,8 +20,11 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC { if message?.isBounced == false { actions.append(pinMessageActionItem()) actions.append(translateActionItem()) - actions.append(reminderActionItem()) - actions.append(saveForLaterActionItem()) + + if AppConfig.shared.demoAppConfig.isRemindersEnabled { + actions.append(reminderActionItem()) + actions.append(saveForLaterActionItem()) + } } if AppConfig.shared.demoAppConfig.isMessageDebuggerEnabled { diff --git a/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift b/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift index a5fb562c451..7c0be2d7f8e 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageLayoutOptionsResolver.swift @@ -29,7 +29,7 @@ final class DemoChatMessageLayoutOptionsResolver: ChatMessageLayoutOptionsResolv options.insert(.pinInfo) } - if message.reminder != nil { + if AppConfig.shared.demoAppConfig.isRemindersEnabled && message.reminder != nil { options.insert(.saveForLaterInfo) } From 3af5f571eb6cace194028efaa364470726f07915 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 21 Mar 2025 18:01:02 +0000 Subject: [PATCH 25/42] Improve the Filter+predicate documentation --- .../StreamChat/Query/Filter+predicate.swift | 58 ++++++++----------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/Sources/StreamChat/Query/Filter+predicate.swift b/Sources/StreamChat/Query/Filter+predicate.swift index 408145a0bd0..62c4e8b560c 100644 --- a/Sources/StreamChat/Query/Filter+predicate.swift +++ b/Sources/StreamChat/Query/Filter+predicate.swift @@ -5,42 +5,11 @@ import Foundation extension Filter { - /// If a valueMapper was provided, then here we will try to transform the value - /// using the mapper. - /// - /// If the mapper returns nil, the original value will be returned - var mappedValue: FilterValue { - valueMapper?(value) ?? value - } - - /// If the mappedValues is an array of FilterValues, we will try to transform them using the valueMapper - /// to ensure that both parts of the comparison are of the same type. - /// - /// If the value is not an array, this value will return nil. - /// If the valueMapper isn't provided or the value mapper returns nil, the original value will be included - /// in the array. - var mappedArrayValue: [FilterValue]? { - guard let filterArray = mappedValue as? [FilterValue] else { - return nil - } - return filterArray.map { valueMapper?($0) ?? $0 } - } - - /// If it can be translated, this will return - /// an NSPredicate instance that is equivalent - /// to the current filter. - /// - /// For now it's limited to ChannelList as it's not - /// needed anywhere else + /// Converts the current filter into an NSPredicate if it can be translated. /// - /// The predicate will be automatically be used - /// by the ChannelDTO to create the - /// fetchRequest. + /// This is useful to make sure the backend filters can be used to filter data in CoreData. /// - /// - Important: - /// The behaviour of the ChannelDTO, to include or not - /// the predicate in the fetchRequest, it's controlled by - /// `ChatClientConfig.isChannelAutomaticFilteringEnabled` + /// **Note:** Extra data properties will be ignored since they are stored in binary format. var predicate: NSPredicate? { guard let op = FilterOperator(rawValue: `operator`) else { return nil @@ -234,6 +203,27 @@ extension Filter { return nil } } + + /// If a valueMapper was provided, then here we will try to transform the value + /// using the mapper. + /// + /// If the mapper returns nil, the original value will be returned + var mappedValue: FilterValue { + valueMapper?(value) ?? value + } + + /// If the mappedValues is an array of FilterValues, we will try to transform them using the valueMapper + /// to ensure that both parts of the comparison are of the same type. + /// + /// If the value is not an array, this value will return nil. + /// If the valueMapper isn't provided or the value mapper returns nil, the original value will be included + /// in the array. + var mappedArrayValue: [FilterValue]? { + guard let filterArray = mappedValue as? [FilterValue] else { + return nil + } + return filterArray.map { valueMapper?($0) ?? $0 } + } } extension String { From 4ea6208810b2ff707d4a4839bfacf65920348c91 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 21 Mar 2025 18:56:30 +0000 Subject: [PATCH 26/42] Fix removeAllData tests --- DemoApp/Screens/DemoAppTabBarController.swift | 8 +------- .../Database/DatabaseContainer_Tests.swift | 10 ++++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index bad0186a9cc..a39edbd01a2 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -98,13 +98,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele // MARK: - Events Controller Setup private func setupEventsController() { - // Get the ChatClient instance from the currentUserController - let client = currentUserController.client - - // Initialize the events controller - eventsController = client.eventsController() - - // Set this class as the delegate for events + eventsController = currentUserController.client.eventsController() eventsController.delegate = self } diff --git a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift index a714f89d6fe..66d33823109 100644 --- a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift @@ -435,6 +435,16 @@ final class DatabaseContainer_Tests: XCTestCase { query: .init(messageId: message.id, filter: .equal(.authorId, to: currentUserId)), cache: nil ) + try session.saveReminder( + payload: .init( + channelCid: cid, + messageId: message.id, + remindAt: .unique, + createdAt: .unique, + updatedAt: .unique + ), + cache: nil + ) } try session.saveMessage( payload: .dummy(channel: .dummy(cid: cid)), From f3cc074e4adc1b5492d9c176ebb9e0094a3d116f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Thu, 27 Mar 2025 15:27:38 +0000 Subject: [PATCH 27/42] Add reminders enabled by default on the demo app --- .../AppConfigViewController/AppConfigViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index b8977a48d26..16dce2e862a 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -53,7 +53,7 @@ class AppConfig { tokenRefreshDetails: nil, shouldShowConnectionBanner: false, isPremiumMemberFeatureEnabled: false, - isRemindersEnabled: false + isRemindersEnabled: true ) if StreamRuntimeCheck.isStreamInternalConfiguration { From 482f9869762a9f13992382fcddb6890f9df6cd4c Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 28 Mar 2025 15:47:11 +0000 Subject: [PATCH 28/42] Remove local notification of reminders since it should come from the server --- DemoApp/Screens/DemoAppTabBarController.swift | 36 +------------------ 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index a39edbd01a2..63afa75dd81 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -7,7 +7,7 @@ import StreamChatUI import UIKit import UserNotifications -class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, EventsControllerDelegate { +class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate { let channelListVC: UIViewController let threadListVC: UIViewController let draftListVC: UIViewController @@ -61,9 +61,6 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele currentUserController.delegate = self unreadCount = currentUserController.unreadCount - - // Initialize events controller - setupEventsController() // Update reminders badge if the feature is enabled. if AppConfig.shared.demoAppConfig.isRemindersEnabled { @@ -95,37 +92,6 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele } } - // MARK: - Events Controller Setup - - private func setupEventsController() { - eventsController = currentUserController.client.eventsController() - eventsController.delegate = self - } - - // MARK: - EventsControllerDelegate - - func eventsController(_ controller: EventsController, didReceiveEvent event: Event) { - if AppConfig.shared.demoAppConfig.isRemindersEnabled, - let reminderDueEvent = event as? ReminderDueEvent { - handleReminderDueEvent(reminderDueEvent) - } - } - - // MARK: - Handle Reminder Due Event - - private func handleReminderDueEvent(_ event: ReminderDueEvent) { - let messageText = event.reminder.message.text - let content = UNMutableNotificationContent() - content.title = "Reminder due" - content.body = messageText - content.sound = .default - - let identifier = "reminder-\(event.messageId)-\(Date().timeIntervalSince1970)" - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) - let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) - notificationCenter.add(request) - } - func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { let unreadCount = didChangeCurrentUserUnreadCount self.unreadCount = unreadCount From 6bfa0a9a18fed978f57ef1ecc7ae5a26d8e4e9a8 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Fri, 28 Mar 2025 20:45:56 +0000 Subject: [PATCH 29/42] Refactor reminders to have their own controller --- DemoApp/Screens/DemoAppTabBarController.swift | 18 +- DemoApp/Screens/DemoReminderListVC.swift | 129 ++++--- .../DemoAppCoordinator+DemoApp.swift | 3 +- .../CurrentUserController.swift | 128 +------ ...essageReminderListController+Combine.swift | 49 +++ .../MessageReminderListController.swift | 200 +++++++++++ StreamChat.xcodeproj/project.pbxproj | 44 ++- .../MessageReminder_Mock.swift | 26 ++ .../RemindersRepository_Mock.swift | 2 + ...urrentUserController+Reminders_Tests.swift | 322 ------------------ ...ReminderListController+Combine_Tests.swift | 82 +++++ .../MessageReminderListController_Tests.swift | 278 +++++++++++++++ 12 files changed, 776 insertions(+), 505 deletions(-) create mode 100644 Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift create mode 100644 Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController.swift create mode 100644 TestTools/StreamChatTestTools/Mocks/Models + Extensions/MessageReminder_Mock.swift delete mode 100644 Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift create mode 100644 Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController+Combine_Tests.swift create mode 100644 Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController_Tests.swift diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 63afa75dd81..7f329470ae9 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -7,13 +7,14 @@ import StreamChatUI import UIKit import UserNotifications -class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate { +class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, MessageReminderListControllerDelegate { let channelListVC: UIViewController let threadListVC: UIViewController let draftListVC: UIViewController let reminderListVC: UIViewController let currentUserController: CurrentChatUserController - + let allRemindersListController: MessageReminderListController + // Events controller for listening to chat events private var eventsController: EventsController! @@ -25,13 +26,15 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele threadListVC: UIViewController, draftListVC: UIViewController, reminderListVC: UIViewController, - currentUserController: CurrentChatUserController + currentUserController: CurrentChatUserController, + allRemindersListController: MessageReminderListController ) { self.channelListVC = channelListVC self.threadListVC = threadListVC self.draftListVC = draftListVC self.reminderListVC = reminderListVC self.currentUserController = currentUserController + self.allRemindersListController = allRemindersListController super.init(nibName: nil, bundle: nil) } @@ -64,6 +67,7 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele // Update reminders badge if the feature is enabled. if AppConfig.shared.demoAppConfig.isRemindersEnabled { + allRemindersListController.delegate = self updateRemindersBadge() } @@ -99,15 +103,15 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge } - func currentUserController( - _ controller: CurrentChatUserController, - didChangeMessageReminders messageReminders: [MessageReminder] + func controller( + _ controller: MessageReminderListController, + didChangeReminders changes: [ListChange] ) { updateRemindersBadge() } private func updateRemindersBadge() { - let reminders = currentUserController.messageReminders + let reminders = allRemindersListController.reminders reminderListVC.tabBarItem.badgeValue = reminders.isEmpty ? nil : "\(reminders.count)" } } diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index 586d7a641b9..914f885907b 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -11,9 +11,17 @@ class DemoReminderListVC: UIViewController, ThemeProvider { var onDisconnect: (() -> Void)? private let currentUserController: CurrentChatUserController + + private var activeController: MessageReminderListController private var reminders: [MessageReminder] = [] private var isPaginatingReminders = false - + + private lazy var allRemindersController = FilterOption.all.makeController(client: currentUserController.client) + private lazy var upcomingRemindersController = FilterOption.upcoming.makeController(client: currentUserController.client) + private lazy var scheduledRemindersController = FilterOption.scheduled.makeController(client: currentUserController.client) + private lazy var laterRemindersController = FilterOption.later.makeController(client: currentUserController.client) + private lazy var overdueRemindersController = FilterOption.overdue.makeController(client: currentUserController.client) + // Timer for refreshing due dates on cells private var refreshTimer: Timer? @@ -30,12 +38,43 @@ class DemoReminderListVC: UIViewController, ThemeProvider { case .later: return "Saved for later" } } + + var query: MessageReminderListQuery { + switch self { + case .all: + return MessageReminderListQuery() + case .scheduled: + return MessageReminderListQuery( + filter: .withRemindAt, + sort: [.init(key: .remindAt, isAscending: true)] + ) + case .later: + return MessageReminderListQuery( + filter: .withoutRemindAt, + sort: [.init(key: .createdAt, isAscending: false)] + ) + case .overdue: + return MessageReminderListQuery( + filter: .overdue, + sort: [.init(key: .remindAt, isAscending: false)] + ) + case .upcoming: + return MessageReminderListQuery( + filter: .upcoming, + sort: [.init(key: .remindAt, isAscending: true)] + ) + } + } + + func makeController(client: ChatClient) -> MessageReminderListController { + client.messageReminderListController(query: query) + } } private var selectedFilter: FilterOption = .all { didSet { if oldValue != selectedFilter { - loadReminders() + switchToController(for: selectedFilter) updateFilterPills() } } @@ -102,6 +141,8 @@ class DemoReminderListVC: UIViewController, ThemeProvider { init(currentUserController: CurrentChatUserController) { self.currentUserController = currentUserController + activeController = currentUserController.client.messageReminderListController() + super.init(nibName: nil, bundle: nil) } @@ -175,37 +216,6 @@ class DemoReminderListVC: UIViewController, ThemeProvider { } } - private func createFilterQuery() -> MessageReminderListQuery { - switch selectedFilter { - case .all: - return MessageReminderListQuery() - - case .scheduled: - return MessageReminderListQuery( - filter: .withRemindAt, - sort: [.init(key: .remindAt, isAscending: true)] - ) - - case .later: - return MessageReminderListQuery( - filter: .withoutRemindAt, - sort: [.init(key: .createdAt, isAscending: false)] - ) - - case .overdue: - return MessageReminderListQuery( - filter: .overdue, - sort: [.init(key: .remindAt, isAscending: false)] - ) - - case .upcoming: - return MessageReminderListQuery( - filter: .upcoming, - sort: [.init(key: .remindAt, isAscending: true)] - ) - } - } - private func setupViews() { view.backgroundColor = Appearance.default.colorPalette.background tableView.backgroundColor = Appearance.default.colorPalette.background @@ -331,26 +341,54 @@ class DemoReminderListVC: UIViewController, ThemeProvider { selectedFilter = filterOption } + private func switchToController(for filter: FilterOption) { + switch filter { + case .all: + activeController = allRemindersController + case .overdue: + activeController = overdueRemindersController + case .upcoming: + activeController = upcomingRemindersController + case .scheduled: + activeController = scheduledRemindersController + case .later: + activeController = laterRemindersController + } + + // Only load reminders if this controller hasn't loaded any yet + if activeController.reminders.isEmpty && !activeController.hasLoadedAllReminders { + loadReminders() + } else { + // Otherwise just update the UI with existing data + reminders = Array(activeController.reminders) + tableView.reloadData() + updateEmptyStateMessage() + emptyStateView.isHidden = !reminders.isEmpty + } + } + private func loadReminders() { - currentUserController.delegate = self + let controller = activeController + controller.delegate = self + if reminders.isEmpty { loadingIndicator.startAnimating() emptyStateView.isHidden = true } - let query = createFilterQuery() - currentUserController.loadReminders(query: query) { [weak self] _ in + controller.synchronize { [weak self] _ in self?.loadingIndicator.stopAnimating() } } private func loadMoreReminders() { - guard !isPaginatingReminders && !currentUserController.hasLoadedAllReminders else { + let controller = activeController + guard !isPaginatingReminders && !controller.hasLoadedAllReminders else { return } isPaginatingReminders = true - currentUserController.loadMoreReminders { [weak self] _ in + controller.loadMoreReminders { [weak self] _ in self?.isPaginatingReminders = false } } @@ -418,14 +456,17 @@ class DemoReminderListVC: UIViewController, ThemeProvider { } } -// MARK: - CurrentChatUserControllerDelegate +// MARK: - MessageReminderListControllerDelegate -extension DemoReminderListVC: CurrentChatUserControllerDelegate { - func currentUserController( - _ controller: CurrentChatUserController, - didChangeMessageReminders messageReminders: [MessageReminder] +extension DemoReminderListVC: MessageReminderListControllerDelegate { + func controller( + _ controller: MessageReminderListController, + didChangeReminders changes: [ListChange] ) { - reminders = messageReminders + // Only update UI if this is the active controller + guard controller === activeController else { return } + + reminders = Array(controller.reminders) tableView.reloadData() updateEmptyStateMessage() emptyStateView.isHidden = !reminders.isEmpty diff --git a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift index 07975e9fe65..604b0ab4278 100644 --- a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift +++ b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift @@ -73,7 +73,8 @@ extension DemoAppCoordinator { threadListVC: UINavigationController(rootViewController: threadListVC), draftListVC: UINavigationController(rootViewController: draftsVC), reminderListVC: UINavigationController(rootViewController: reminderListVC), - currentUserController: client.currentUserController() + currentUserController: client.currentUserController(), + allRemindersListController: client.messageReminderListController() ) set(rootViewController: tabBarViewController, animated: animated) DemoAppConfiguration.showPerformanceTracker() diff --git a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 861d8e943c8..8397e77e252 100644 --- a/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift +++ b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift @@ -112,33 +112,6 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt return Array(observer.items) } - // MARK: - Message Reminders Properties - - /// The query used for fetching the message reminders. - private var reminderListQuery = MessageReminderListQuery() - - /// Use for observing the current user's message reminders changes. - private var messageRemindersObserver: BackgroundListDatabaseObserver? - - /// The repository for message reminders. - private var remindersRepository: RemindersRepository - - /// The token for the next page of message reminders. - private var messageRemindersNextCursor: String? - - /// A flag to indicate whether all message reminders have been loaded. - public private(set) var hasLoadedAllReminders: Bool = false - - /// The current user's message reminders. - public var messageReminders: [MessageReminder] { - if let observer = messageRemindersObserver { - return Array(observer.items) - } - - let observer = createMessageRemindersObserver(query: reminderListQuery) - return Array(observer.items) - } - // MARK: - Init /// Creates a new `CurrentUserControllerGeneric`. @@ -151,7 +124,7 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt self.client = client self.environment = environment draftMessagesRepository = client.draftMessagesRepository - remindersRepository = client.remindersRepository + super.init() } /// Synchronize local data with remote. Waits for the client to connect but doesn't initiate the connection itself. @@ -465,68 +438,6 @@ public extension CurrentChatUserController { } } } - - /// Loads the message reminders for the current user given the provided query. - /// - /// It will load the first page of reminders of the current user. - /// `loadMoreReminders` can be used to load the next pages. - /// - /// - Parameters: - /// - query: The query for filtering the reminders. - /// - completion: Called when the API call is finished. - /// It is optional since it can be observed from the delegate events. - func loadReminders( - query: MessageReminderListQuery = MessageReminderListQuery(), - completion: ((Result<[MessageReminder], Error>) -> Void)? = nil - ) { - reminderListQuery = query - createMessageRemindersObserver(query: query) - remindersRepository.loadReminders(query: query) { result in - self.callback { - switch result { - case let .success(response): - self.messageRemindersNextCursor = response.next - self.hasLoadedAllReminders = response.next == nil - completion?(.success(response.reminders)) - case let .failure(error): - completion?(.failure(error)) - } - } - } - } - - /// Loads more message reminders for the current user. - /// - /// - Parameters: - /// - limit: The number of message reminders to load. If `nil`, the default limit will be used. - /// - completion: Called when the API call is finished. - /// It is optional since it can be observed from the delegate events. - func loadMoreReminders( - limit: Int? = nil, - completion: ((Result<[MessageReminder], Error>) -> Void)? = nil - ) { - guard let nextCursor = messageRemindersNextCursor else { - completion?(.success([])) - return - } - - let limit = limit ?? reminderListQuery.pagination.pageSize - var updatedQuery = reminderListQuery - updatedQuery.pagination = Pagination(pageSize: limit, cursor: nextCursor) - - remindersRepository.loadReminders(query: updatedQuery) { result in - self.callback { - switch result { - case let .success(response): - self.messageRemindersNextCursor = response.next - self.hasLoadedAllReminders = response.next == nil - completion?(.success(response.reminders)) - case let .failure(error): - completion?(.failure(error)) - } - } - } - } } // MARK: - Environment @@ -547,14 +458,6 @@ extension CurrentChatUserController { ) -> BackgroundListDatabaseObserver = { .init(database: $0, fetchRequest: $1, itemCreator: $2, itemReuseKeyPaths: (\DraftMessage.id, \MessageDTO.id)) } - - var messageRemindersObserverBuilder: ( - _ database: DatabaseContainer, - _ fetchRequest: NSFetchRequest, - _ itemCreator: @escaping (MessageReminderDTO) throws -> MessageReminder - ) -> BackgroundListDatabaseObserver = { - .init(database: $0, fetchRequest: $1, itemCreator: $2, itemReuseKeyPaths: (\MessageReminder.id, \MessageReminderDTO.id)) - } var currentUserUpdaterBuilder = CurrentUserUpdater.init } @@ -606,24 +509,6 @@ private extension CurrentChatUserController { draftMessagesObserver = observer return observer } - - @discardableResult - private func createMessageRemindersObserver(query: MessageReminderListQuery) -> BackgroundListDatabaseObserver { - let observer = environment.messageRemindersObserverBuilder( - client.databaseContainer, - MessageReminderDTO.remindersFetchRequest(query: query), - { try $0.asModel() } - ) - observer.onDidChange = { [weak self] _ in - guard let self = self else { return } - self.delegateCallback { - $0.currentUserController(self, didChangeMessageReminders: self.messageReminders) - } - } - try? observer.startObserving() - messageRemindersObserver = observer - return observer - } } // MARK: - Delegates @@ -641,12 +526,6 @@ public protocol CurrentChatUserControllerDelegate: AnyObject { _ controller: CurrentChatUserController, didChangeDraftMessages draftMessages: [DraftMessage] ) - - /// The controller observed a change in the message reminders. - func currentUserController( - _ controller: CurrentChatUserController, - didChangeMessageReminders messageReminders: [MessageReminder] - ) } public extension CurrentChatUserControllerDelegate { @@ -658,11 +537,6 @@ public extension CurrentChatUserControllerDelegate { _ controller: CurrentChatUserController, didChangeDraftMessages draftMessages: [DraftMessage] ) {} - - func currentUserController( - _ controller: CurrentChatUserController, - didChangeMessageReminders messageReminders: [MessageReminder] - ) {} } public extension CurrentChatUserController { diff --git a/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift new file mode 100644 index 00000000000..93663f28ba2 --- /dev/null +++ b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift @@ -0,0 +1,49 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +@available(iOS 13, *) +extension MessageReminderListController { + /// A publisher emitting a new value every time the state of the controller changes. + public var statePublisher: AnyPublisher { + basePublishers.state.keepAlive(self) + } + + /// A publisher emitting a new value every time the reminders change. + public var remindersChangesPublisher: AnyPublisher<[ListChange], Never> { + basePublishers.remindersChanges.keepAlive(self) + } + + /// An internal backing object for all publicly available Combine publishers. + class BasePublishers { + /// A backing publisher for `statePublisher`. + let state: CurrentValueSubject + + /// A backing publisher for `remindersChangesPublisher`. + let remindersChanges: PassthroughSubject<[ListChange], Never> + + init(controller: MessageReminderListController) { + state = .init(controller.state) + remindersChanges = .init() + + controller.multicastDelegate.add(additionalDelegate: self) + } + } +} + +@available(iOS 13, *) +extension MessageReminderListController.BasePublishers: MessageReminderListControllerDelegate { + func controller(_ controller: DataController, didChangeState state: DataController.State) { + self.state.send(state) + } + + func controller( + _ controller: MessageReminderListController, + didChangeReminders changes: [ListChange] + ) { + remindersChanges.send(changes) + } +} diff --git a/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController.swift b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController.swift new file mode 100644 index 00000000000..f87f63317fe --- /dev/null +++ b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController.swift @@ -0,0 +1,200 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation + +public extension ChatClient { + /// Creates and returns a `MessageReminderListController` for the specified query. + /// + /// - Parameter query: The query object defining the criteria for retrieving the list of message reminders. + /// - Returns: A `MessageReminderListController` initialized with the provided query and client. + func messageReminderListController(query: MessageReminderListQuery = .init()) -> MessageReminderListController { + .init(query: query, client: self) + } +} + +/// `MessageReminderListController` uses this protocol to communicate changes to its delegate. +public protocol MessageReminderListControllerDelegate: DataControllerStateDelegate { + /// The controller changed the list of observed reminders. + /// + /// - Parameters: + /// - controller: The controller emitting the change callback. + /// - changes: The change to the list of reminders. + func controller( + _ controller: MessageReminderListController, + didChangeReminders changes: [ListChange] + ) +} + +/// A controller which allows querying and filtering message reminders. +public class MessageReminderListController: DataController, DelegateCallable, DataStoreProvider { + /// The query specifying and filtering the list of reminders. + public let query: MessageReminderListQuery + + /// The `ChatClient` instance this controller belongs to. + public let client: ChatClient + + /// The message reminders the controller represents. + /// + /// To observe changes of the reminders, set your class as a delegate of this controller or use the provided + /// `Combine` publishers. + public var reminders: LazyCachedMapCollection { + startMessageRemindersObserverIfNeeded() + return messageRemindersObserver.items + } + + /// A Boolean value that returns whether pagination is finished. + public private(set) var hasLoadedAllReminders: Bool = false + + /// Set the delegate of `MessageReminderListController` to observe the changes in the system. + public weak var delegate: MessageReminderListControllerDelegate? { + get { multicastDelegate.mainDelegate } + set { multicastDelegate.set(mainDelegate: newValue) } + } + + /// A type-erased delegate. + var multicastDelegate: MulticastDelegate = .init() { + didSet { + stateMulticastDelegate.set(mainDelegate: multicastDelegate.mainDelegate) + stateMulticastDelegate.set(additionalDelegates: multicastDelegate.additionalDelegates) + + // After setting delegate local changes will be fetched and observed. + startMessageRemindersObserverIfNeeded() + } + } + + /// Used for observing the database for changes. + private(set) lazy var messageRemindersObserver: BackgroundListDatabaseObserver = { + let request = MessageReminderDTO.remindersFetchRequest(query: query) + + let observer = self.environment.createMessageReminderListDatabaseObserver( + client.databaseContainer, + request, + { try $0.asModel() } + ) + + observer.onDidChange = { [weak self] changes in + self?.delegateCallback { [weak self] in + guard let self = self else { + log.warning("Callback called while self is nil") + return + } + + $0.controller(self, didChangeReminders: changes) + } + } + + return observer + }() + + var _basePublishers: Any? + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose + /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, + /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + var basePublishers: BasePublishers { + if let value = _basePublishers as? BasePublishers { + return value + } + _basePublishers = BasePublishers(controller: self) + return _basePublishers as? BasePublishers ?? .init(controller: self) + } + + private let remindersRepository: RemindersRepository + private let environment: Environment + private var nextCursor: String? + + /// Creates a new `MessageReminderListController`. + /// + /// - Parameters: + /// - query: The query used for filtering the reminders. + /// - client: The `Client` instance this controller belongs to. + init(query: MessageReminderListQuery, client: ChatClient, environment: Environment = .init()) { + self.client = client + self.query = query + self.environment = environment + remindersRepository = client.remindersRepository + super.init() + } + + override public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { + startMessageRemindersObserverIfNeeded() + + remindersRepository.loadReminders(query: query) { [weak self] result in + guard let self else { return } + if let value = result.value { + self.nextCursor = value.next + self.hasLoadedAllReminders = value.next == nil + } + if let error = result.error { + self.state = .remoteDataFetchFailed(ClientError(with: error)) + } else { + self.state = .remoteDataFetched + } + self.callback { completion?(result.error) } + } + } + + /// If the `state` of the controller is `initialized`, this method calls `startObserving` on the + /// `messageRemindersObserver` to fetch the local data and start observing the changes. It also changes + /// `state` based on the result. + private func startMessageRemindersObserverIfNeeded() { + guard state == .initialized else { return } + do { + try messageRemindersObserver.startObserving() + state = .localDataFetched + } catch { + state = .localDataFetchFailed(ClientError(with: error)) + log.error("Failed to perform fetch request with error: \(error). This is an internal error.") + } + } + + // MARK: - Actions + + /// Loads more reminders. + /// + /// - Parameters: + /// - limit: Limit for the page size. + /// - completion: The completion callback. + public func loadMoreReminders( + limit: Int? = nil, + completion: ((Result<[MessageReminder], Error>) -> Void)? = nil + ) { + let limit = limit ?? query.pagination.pageSize + var updatedQuery = query + updatedQuery.pagination = Pagination(pageSize: limit, cursor: nextCursor) + remindersRepository.loadReminders(query: updatedQuery) { [weak self] result in + switch result { + case let .success(value): + self?.callback { + self?.nextCursor = value.next + self?.hasLoadedAllReminders = value.next == nil + completion?(.success(value.reminders)) + } + case let .failure(error): + self?.callback { + completion?(.failure(error)) + } + } + } + } +} + +extension MessageReminderListController { + struct Environment { + var createMessageReminderListDatabaseObserver: ( + _ database: DatabaseContainer, + _ fetchRequest: NSFetchRequest, + _ itemCreator: @escaping (MessageReminderDTO) throws -> MessageReminder + ) + -> BackgroundListDatabaseObserver = { + BackgroundListDatabaseObserver( + database: $0, + fetchRequest: $1, + itemCreator: $2, + itemReuseKeyPaths: (\MessageReminder.id, \MessageReminderDTO.id) + ) + } + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b6522872f56..541faef40ab 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1672,6 +1672,13 @@ ADA3572F269C807A004AD8E9 /* ChatChannelHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */; }; ADA5A0F8276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */; }; ADA5A0F9276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */; }; + ADA83B3E2D974DCC003B3928 /* MessageReminderListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B3C2D974DCB003B3928 /* MessageReminderListController.swift */; }; + ADA83B3F2D974DCC003B3928 /* MessageReminderListController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B3D2D974DCB003B3928 /* MessageReminderListController+Combine.swift */; }; + ADA83B402D974DCC003B3928 /* MessageReminderListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B3C2D974DCB003B3928 /* MessageReminderListController.swift */; }; + ADA83B412D974DCC003B3928 /* MessageReminderListController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B3D2D974DCB003B3928 /* MessageReminderListController+Combine.swift */; }; + ADA83B452D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B432D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift */; }; + ADA83B472D976D9C003B3928 /* MessageReminderListController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B462D976D9C003B3928 /* MessageReminderListController_Tests.swift */; }; + ADA83B492D976ED7003B3928 /* MessageReminder_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */; }; ADA8EBE928CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */; }; ADA8EBEB28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */; }; ADA9DB892BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA9DB882BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift */; }; @@ -1698,7 +1705,6 @@ ADB8B8F72D8B846D00549C95 /* RemindersRepository_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F62D8B846D00549C95 /* RemindersRepository_Tests.swift */; }; ADB8B8F92D8B8A0C00549C95 /* RemindersRepository_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */; }; ADB8B8FB2D8B904D00549C95 /* MessageController+Reminders_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */; }; - ADB8B8FD2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */; }; ADB8B9022D8C701000549C95 /* ReminderUpdated.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9012D8C700800549C95 /* ReminderUpdated.json */; }; ADB8B9042D8C701500549C95 /* ReminderCreated.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9032D8C701500549C95 /* ReminderCreated.json */; }; ADB8B9062D8C702A00549C95 /* ReminderDeleted.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B9052D8C701F00549C95 /* ReminderDeleted.json */; }; @@ -4411,6 +4417,11 @@ ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatChannelListErrorView.swift; sourceTree = ""; }; ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelHeaderView.swift; sourceTree = ""; }; ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListDateSeparatorView.swift; sourceTree = ""; }; + ADA83B3C2D974DCB003B3928 /* MessageReminderListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderListController.swift; sourceTree = ""; }; + ADA83B3D2D974DCB003B3928 /* MessageReminderListController+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReminderListController+Combine.swift"; sourceTree = ""; }; + ADA83B432D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReminderListController+Combine_Tests.swift"; sourceTree = ""; }; + ADA83B462D976D9C003B3928 /* MessageReminderListController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderListController_Tests.swift; sourceTree = ""; }; + ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder_Mock.swift; sourceTree = ""; }; ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewUserMentionsHandler_Mock.swift; sourceTree = ""; }; ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContentViewDelegate_Mock.swift; sourceTree = ""; }; ADA9DB882BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReadDTO_Tests.swift; sourceTree = ""; }; @@ -4432,7 +4443,6 @@ ADB8B8F62D8B846D00549C95 /* RemindersRepository_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersRepository_Tests.swift; sourceTree = ""; }; ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersRepository_Mock.swift; sourceTree = ""; }; ADB8B8FA2D8B904D00549C95 /* MessageController+Reminders_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageController+Reminders_Tests.swift"; sourceTree = ""; }; - ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUserController+Reminders_Tests.swift"; sourceTree = ""; }; ADB8B9012D8C700800549C95 /* ReminderUpdated.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderUpdated.json; sourceTree = ""; }; ADB8B9032D8C701500549C95 /* ReminderCreated.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderCreated.json; sourceTree = ""; }; ADB8B9052D8C701F00549C95 /* ReminderDeleted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ReminderDeleted.json; sourceTree = ""; }; @@ -6027,6 +6037,7 @@ DAE566F22500F97E00E39431 /* ChannelController */, DAE566F32500F98D00E39431 /* ChannelListController */, 79C5CBF925F671AE00D98001 /* ChannelWatcherListController */, + ADA83B352D9742CD003B3928 /* MessageReminderListController */, AD9490552BF3BA8000E69224 /* ThreadListController */, ADF34F6925CD6A0100AD637C /* ConnectionController */, DAE566F42500F99900E39431 /* CurrentUserController */, @@ -6869,6 +6880,7 @@ A344074F27D753530044F150 /* Models + Extensions */ = { isa = PBXGroup; children = ( + ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */, ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */, A344075027D753530044F150 /* ChannelUnreadCount_Mock.swift */, A344075127D753530044F150 /* ChatChannel_Mock.swift */, @@ -7382,6 +7394,7 @@ A364D0A527D127E00029857A /* Controllers */ = { isa = PBXGroup; children = ( + ADA83B442D97511E003B3928 /* MessageReminderListController */, AD94905E2BF65CC500E69224 /* ThreadListController */, A364D0A827D128650029857A /* ChannelController */, A364D0A927D128830029857A /* ChannelListController */, @@ -7463,7 +7476,6 @@ A364D0AD27D1291E0029857A /* CurrentUserController */ = { isa = PBXGroup; children = ( - ADB8B8FC2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift */, AD545E822D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift */, F69E7F7C24ED7562000F5252 /* CurrentUserController_Tests.swift */, DA4AA3B5250271B100FAAF6E /* CurrentUserController+Combine_Tests.swift */, @@ -8829,6 +8841,24 @@ path = TitleContainerView; sourceTree = ""; }; + ADA83B352D9742CD003B3928 /* MessageReminderListController */ = { + isa = PBXGroup; + children = ( + ADA83B3C2D974DCB003B3928 /* MessageReminderListController.swift */, + ADA83B3D2D974DCB003B3928 /* MessageReminderListController+Combine.swift */, + ); + path = MessageReminderListController; + sourceTree = ""; + }; + ADA83B442D97511E003B3928 /* MessageReminderListController */ = { + isa = PBXGroup; + children = ( + ADA83B462D976D9C003B3928 /* MessageReminderListController_Tests.swift */, + ADA83B432D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift */, + ); + path = MessageReminderListController; + sourceTree = ""; + }; ADAA10E92B90D554007AB03F /* FakeTimer */ = { isa = PBXGroup; children = ( @@ -11335,6 +11365,7 @@ A3C3BC4227E87F5C00224761 /* UserListUpdater_Mock.swift in Sources */, A344078227D753530044F150 /* NSManagedObject+ContextChange.swift in Sources */, 8459C9EA2BFB39DC00F0D235 /* PollController_Mock.swift in Sources */, + ADA83B492D976ED7003B3928 /* MessageReminder_Mock.swift in Sources */, A3C3BC2627E87F2000224761 /* TestAttachmentEnvelope.swift in Sources */, A344077E27D753530044F150 /* ChatMessageLinkAttachment_Mock.swift in Sources */, A3C3BC9427E8AC0600224761 /* RequestEncoder_Spy.swift in Sources */, @@ -11456,6 +11487,8 @@ 79877A0A2498E4BC00015F8B /* Device.swift in Sources */, 841BAA0D2BCE9F44000C73E4 /* UpdatePollOptionRequestBody.swift in Sources */, 841BA9F82BCE80FF000C73E4 /* PollsPayloads.swift in Sources */, + ADA83B3E2D974DCC003B3928 /* MessageReminderListController.swift in Sources */, + ADA83B3F2D974DCC003B3928 /* MessageReminderListController+Combine.swift in Sources */, DABC6AC8254707CB00A8FC78 /* AttachmentDTO.swift in Sources */, 40789D1329F6AC500018C2BB /* AudioPlaybackContext.swift in Sources */, 22692C9725D1841E007C41D0 /* ChatMessageFileAttachment.swift in Sources */, @@ -11959,6 +11992,7 @@ 8A0D64AE24E5853F0017A3C0 /* DataController_Tests.swift in Sources */, 8486CAF926FA51EE00A9AD96 /* EventDTOConverterMiddleware_Tests.swift in Sources */, ADEDA1FA2B2BC46C00020460 /* RepeatingTimer_Tests.swift in Sources */, + ADA83B472D976D9C003B3928 /* MessageReminderListController_Tests.swift in Sources */, C14A46562845064E00EF498E /* ThreadSafeWeakCollection_Tests.swift in Sources */, 841BAA152BD01901000C73E4 /* PollPayload_Tests.swift in Sources */, ADC40C3226E26E9F005B616C /* UserSearchController_Tests.swift in Sources */, @@ -12116,7 +12150,6 @@ 8AC9CBD424C7351D006E236C /* ReactionEvents_Tests.swift in Sources */, C11B575629D20F3600D5A248 /* User_Tests.swift in Sources */, 8AC9CBE424C74ECB006E236C /* NotificationEvents_Tests.swift in Sources */, - ADB8B8FD2D8B966E00549C95 /* CurrentUserController+Reminders_Tests.swift in Sources */, 405D172D2A03E57C00A77C3B /* AVAssetTotalAudioSamples_Tests.swift in Sources */, ADE57B892C3C626100DD6B88 /* ThreadEvents_Tests.swift in Sources */, 84EB4E78276A03DE00E47E73 /* ErrorPayload_Tests.swift in Sources */, @@ -12129,6 +12162,7 @@ C111B5B628CF3B1200C79D53 /* BackgroundListDatabaseObserver_Tests.swift in Sources */, A3C7BAD327E4E05300BBF4FA /* MemberListFilterScope_Tests.swift in Sources */, 8A0D649D24E579F70017A3C0 /* GuestUserTokenPayload_Tests.swift in Sources */, + ADA83B452D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift in Sources */, AD9490622BF66D1E00E69224 /* ThreadsRepository_Mock.swift in Sources */, 8A0C3BE224C1F74200CAFD19 /* MessageEvents_Tests.swift in Sources */, 4F6A77042D2FD0A00019CAF8 /* AppSettings_Tests.swift in Sources */, @@ -12395,6 +12429,8 @@ C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */, 4F8E53172B7F58C1008C0F9F /* ChatClient+Factory.swift in Sources */, C121E82A274544AD00023E4C /* ConnectionStatus.swift in Sources */, + ADA83B402D974DCC003B3928 /* MessageReminderListController.swift in Sources */, + ADA83B412D974DCC003B3928 /* MessageReminderListController+Combine.swift in Sources */, C121E82B274544AD00023E4C /* APIPathConvertible.swift in Sources */, AD8FEE5C2AA8E1E400273F88 /* ChatClientFactory.swift in Sources */, 40789D2E29F6AC500018C2BB /* AudioRecordingState.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/MessageReminder_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/MessageReminder_Mock.swift new file mode 100644 index 00000000000..e2ec5bfe7cc --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/MessageReminder_Mock.swift @@ -0,0 +1,26 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat + +public extension MessageReminder { + static func mock( + id: String = .unique, + remindAt: Date? = nil, + message: ChatMessage = .mock(), + channel: ChatChannel = .mock(cid: .unique), + createdAt: Date = .init(), + updatedAt: Date = .init() + ) -> MessageReminder { + .init( + id: id, + remindAt: remindAt, + message: message, + channel: channel, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift index c98ff59894b..b0eedd0a9fe 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift @@ -8,6 +8,7 @@ import XCTest /// Mock implementation of RemindersRepository final class RemindersRepository_Mock: RemindersRepository { + var loadReminders_callCount: Int = 0 var loadReminders_query: MessageReminderListQuery? var loadReminders_completion: ((Result) -> Void)? var loadReminders_completion_result: Result? @@ -67,6 +68,7 @@ final class RemindersRepository_Mock: RemindersRepository { query: MessageReminderListQuery, completion: @escaping ((Result) -> Void) ) { + loadReminders_callCount += 1 loadReminders_query = query loadReminders_completion = completion diff --git a/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift b/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift deleted file mode 100644 index 6b21ae0374b..00000000000 --- a/Tests/StreamChatTests/Controllers/CurrentUserController/CurrentUserController+Reminders_Tests.swift +++ /dev/null @@ -1,322 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class CurrentUserController_Reminders_Tests: XCTestCase { - var client: ChatClient_Mock! - var controller: CurrentChatUserController! - var remindersRepository: RemindersRepository_Mock! - - override func setUp() { - super.setUp() - - client = ChatClient.mock - remindersRepository = client.mockRemindersRepository - - controller = CurrentChatUserController(client: client) - } - - override func tearDown() { - client.cleanUp() - remindersRepository = nil - controller = nil - client = nil - - super.tearDown() - } - - // MARK: - Load Reminders Tests - - func test_loadReminders_whenSuccessful() { - // Create test data - let reminders = [ - MessageReminder( - id: .unique, - remindAt: Date(), - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - ), - MessageReminder( - id: .unique, - remindAt: nil, // "save for later" type reminder - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - ) - ] - - // Setup expectation - let expectation = expectation(description: "loadReminders completion called") - var receivedResult: Result<[MessageReminder], Error>? - - // Call method being tested - controller.loadReminders { result in - receivedResult = result - expectation.fulfill() - } - - // Provide the mock response after the call - remindersRepository.loadReminders_completion?(.success( - ReminderListResponse(reminders: reminders, next: nil) - )) - - // Wait for completion - waitForExpectations(timeout: defaultTimeout) - - // Verify results - XCTAssertEqual(try? receivedResult?.get().count, 2) - XCTAssertEqual(controller.hasLoadedAllReminders, true) - XCTAssertNotNil(remindersRepository.loadReminders_query) - } - - func test_loadReminders_withPagination() { - // Create test data - let reminders = [MessageReminder( - id: .unique, - remindAt: Date(), - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - )] - - // Set up next cursor - let nextCursor = "next_page_token" - - // Setup expectation - let expectation = expectation(description: "loadReminders completion called") - var receivedResult: Result<[MessageReminder], Error>? - - // Call method being tested - let query = MessageReminderListQuery(pageSize: 10) - controller.loadReminders(query: query) { result in - receivedResult = result - expectation.fulfill() - } - - // Provide the mock response after the call - remindersRepository.loadReminders_completion?(.success( - ReminderListResponse(reminders: reminders, next: nextCursor) - )) - - // Wait for completion - waitForExpectations(timeout: defaultTimeout) - - // Verify results - XCTAssertEqual(try? receivedResult?.get().count, 1) - XCTAssertEqual(controller.hasLoadedAllReminders, false) - XCTAssertEqual(remindersRepository.loadReminders_query?.pagination.pageSize, 10) - } - - func test_loadReminders_whenFailure() { - // Mock repository error - let testError = TestError() - - // Setup expectation - let expectation = expectation(description: "loadReminders completion called") - var receivedError: Error? - - // Call method being tested - controller.loadReminders { result in - if case let .failure(error) = result { - receivedError = error - } - expectation.fulfill() - } - - // Provide the mock error response after the call - remindersRepository.loadReminders_completion?(.failure(testError)) - - // Wait for completion - waitForExpectations(timeout: defaultTimeout) - - // Verify error is passed through - XCTAssertEqual(receivedError as? TestError, testError) - } - - // MARK: - Load More Reminders Tests - - func test_loadMoreReminders_whenSuccessful() { - // First load initial page - let initialReminders = [MessageReminder( - id: .unique, - remindAt: Date(), - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - )] - let nextCursor = "test_cursor" - - // Call initial load - controller.loadReminders { _ in } - remindersRepository.loadReminders_completion?(.success( - ReminderListResponse(reminders: initialReminders, next: nextCursor) - )) - - // Create test data for second page - let moreReminders = [MessageReminder( - id: .unique, - remindAt: Date(), - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - )] - - // Setup expectation for loadMoreReminders - let expectation = expectation(description: "loadMoreReminders completion called") - var receivedResult: Result<[MessageReminder], Error>? - - // Call method being tested - controller.loadMoreReminders(limit: 20) { result in - receivedResult = result - expectation.fulfill() - } - - // Provide the mock response after the call - remindersRepository.loadReminders_completion?(.success( - ReminderListResponse(reminders: moreReminders, next: nil) - )) - - // Wait for completion - waitForExpectations(timeout: defaultTimeout) - - // Verify results - XCTAssertEqual(try? receivedResult?.get().count, 1) - XCTAssertEqual(controller.hasLoadedAllReminders, true) - } - - func test_loadMoreReminders_withNoCursor() { - // Setup expectation - let expectation = expectation(description: "loadMoreReminders completion called") - var receivedResult: Result<[MessageReminder], Error>? - - // Call method being tested with no cursor set - controller.loadMoreReminders { result in - receivedResult = result - expectation.fulfill() - } - - // Wait for completion - waitForExpectations(timeout: defaultTimeout) - - // Verify no API call was made and empty result returned - XCTAssertEqual(try? receivedResult?.get().count, 0) - } - - func test_loadMoreReminders_whenFailure() { - // First load initial page - let initialReminders = [MessageReminder( - id: .unique, - remindAt: Date(), - message: .mock(), - channel: .mockDMChannel(), - createdAt: .init(), - updatedAt: .init() - )] - let nextCursor = "test_cursor" - - // Call initial load - controller.loadReminders { _ in } - remindersRepository.loadReminders_completion?(.success( - ReminderListResponse(reminders: initialReminders, next: nextCursor) - )) - - // Setup error for next page - let testError = TestError() - - // Setup expectation - let expectation = expectation(description: "loadMoreReminders completion called") - var receivedError: Error? - - // Call method being tested - controller.loadMoreReminders { result in - if case let .failure(error) = result { - receivedError = error - } - expectation.fulfill() - } - - // Provide the mock error response after the call - remindersRepository.loadReminders_completion?(.failure(testError)) - - // Wait for completion - waitForExpectations(timeout: defaultTimeout) - - // Verify error is passed through - XCTAssertEqual(receivedError as? TestError, testError) - } - - // MARK: - Delegate Tests - - func test_messageRemindersObserver_notifiesDelegate() throws { - class DelegateMock: CurrentChatUserControllerDelegate { - var reminders: [MessageReminder] = [] - let expectation = XCTestExpectation(description: "Did Change Message Reminders") - let expectedRemindersCount: Int - - init(expectedRemindersCount: Int) { - self.expectedRemindersCount = expectedRemindersCount - } - - func currentUserController( - _ controller: CurrentChatUserController, - didChangeMessageReminders reminders: [MessageReminder] - ) { - self.reminders = reminders - guard expectedRemindersCount == reminders.count else { return } - expectation.fulfill() - } - } - - let delegate = DelegateMock(expectedRemindersCount: 2) - controller.loadReminders() - controller.delegate = delegate - - try client.databaseContainer.writeSynchronously { session in - let date = Date.unique - let cid = ChannelId.unique - let messageId1 = MessageId.unique - let messageId2 = MessageId.unique - - try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) - try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) - try session.saveMessage(payload: .dummy(messageId: messageId1), for: cid, syncOwnReactions: false, cache: nil) - try session.saveMessage(payload: .dummy(messageId: messageId2), for: cid, syncOwnReactions: false, cache: nil) - - // Create test reminders with different dates - let reminders = [ - ReminderPayload( - channelCid: cid, - messageId: messageId1, - remindAt: date, - createdAt: date, - updatedAt: date - ), - ReminderPayload( - channelCid: cid, - messageId: messageId2, - remindAt: date.addingTimeInterval(3600), // 1 hour later - createdAt: date, - updatedAt: date - ) - ] - - try reminders.forEach { - try session.saveReminder(payload: $0, cache: nil) - } - } - - wait(for: [delegate.expectation], timeout: defaultTimeout) - XCTAssertEqual(controller.messageReminders.count, 2) - XCTAssertEqual(delegate.reminders.count, 2) - } -} diff --git a/Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController+Combine_Tests.swift b/Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController+Combine_Tests.swift new file mode 100644 index 00000000000..456f7eff78f --- /dev/null +++ b/Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController+Combine_Tests.swift @@ -0,0 +1,82 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class MessageReminderListController_Combine_Tests: iOS13TestCase { + var reminderListController: MessageReminderListController! + var cancellables: Set! + var client: ChatClient_Mock! + + override func setUp() { + super.setUp() + client = ChatClient_Mock.mock + reminderListController = MessageReminderListController( + query: .init(), + client: client + ) + cancellables = [] + } + + override func tearDown() { + // Release existing subscriptions and make sure the controller gets released, too + cancellables = nil + AssertAsync.canBeReleased(&reminderListController) + reminderListController = nil + super.tearDown() + } + + func test_statePublisher() { + // Setup Recording publishers + var recording = Record.Recording() + + // Setup the chain + reminderListController + .statePublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: MessageReminderListController? = reminderListController + reminderListController = nil + + controller?.delegateCallback { $0.controller(controller!, didChangeState: .remoteDataFetched) } + + AssertAsync.willBeEqual(recording.output, [.localDataFetched, .remoteDataFetched]) + } + + func test_remindersChangesPublisher() { + // Setup Recording publishers + var recording = Record<[ListChange], Never>.Recording() + + // Setup the chain + reminderListController + .remindersChangesPublisher + .sink(receiveValue: { recording.receive($0) }) + .store(in: &cancellables) + + // Keep only the weak reference to the controller. The existing publisher should keep it alive. + weak var controller: MessageReminderListController? = reminderListController + reminderListController = nil + + let reminder = MessageReminder( + id: .unique, + remindAt: nil, + message: .unique, + channel: .mock(cid: .unique), + createdAt: .unique, + updatedAt: .unique + ) + let changes: [ListChange] = .init([.insert(reminder, index: .init())]) + controller?.delegateCallback { + $0.controller(controller!, didChangeReminders: changes) + } + + XCTAssertEqual(recording.output, .init(arrayLiteral: [.insert(reminder, index: .init())])) + } +} diff --git a/Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController_Tests.swift b/Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController_Tests.swift new file mode 100644 index 00000000000..1bad4cbf070 --- /dev/null +++ b/Tests/StreamChatTests/Controllers/MessageReminderListController/MessageReminderListController_Tests.swift @@ -0,0 +1,278 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class MessageReminderListController_Tests: XCTestCase { + var client: ChatClient_Mock! + var controller: MessageReminderListController! + var repositoryMock: RemindersRepository_Mock! + + override func setUp() { + super.setUp() + client = ChatClient.mock + repositoryMock = client.remindersRepository as? RemindersRepository_Mock + controller = makeController() + } + + override func tearDown() { + client.cleanUp() + repositoryMock = nil + controller = nil + super.tearDown() + } + + func test_synchronize_whenSuccess() { + let exp = expectation(description: "synchronize completion") + controller.synchronize { error in + XCTAssertNil(error) + exp.fulfill() + } + XCTAssertEqual(repositoryMock.loadReminders_callCount, 1) + + repositoryMock.loadReminders_completion?(.success(.init(reminders: [ + .mock(), + .mock() + ], next: nil))) + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertEqual(controller.state, .remoteDataFetched) + XCTAssertTrue(controller.hasLoadedAllReminders) + } + + func test_synchronize_whenSuccess_whenMoreReminders() { + let exp = expectation(description: "synchronize completion") + var query = MessageReminderListQuery(pageSize: 2) + controller = makeController(query: query) + controller.synchronize { error in + XCTAssertNil(error) + exp.fulfill() + } + + repositoryMock.loadReminders_completion?(.success(.init(reminders: [ + .mock(), + .mock(), + .mock(), + .mock() + ], next: .unique))) + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertFalse(controller.hasLoadedAllReminders) + } + + func test_synchronize_whenFailure() { + let exp = expectation(description: "synchronize completion") + controller.synchronize { error in + XCTAssertNotNil(error) + exp.fulfill() + } + repositoryMock.loadReminders_completion?(.failure(ClientError())) + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertFalse(controller.hasLoadedAllReminders) + switch controller.state { + case .remoteDataFetchFailed: + break + default: + XCTFail() + } + } + + func test_loadMoreReminders_whenSuccess() { + let exp = expectation(description: "loadMoreReminders completion") + controller.loadMoreReminders() { result in + let reminders = try? result.get() + XCTAssertNotNil(reminders) + exp.fulfill() + } + XCTAssertEqual(repositoryMock.loadReminders_query?.pagination.pageSize, controller.query.pagination.pageSize) + + repositoryMock.loadReminders_completion?(.success(.init(reminders: [ + .mock(), + .mock() + ]))) + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertTrue(controller.hasLoadedAllReminders) + } + + func test_loadMoreReminders_whenSuccess_whenMoreReminders() { + let exp = expectation(description: "loadMoreReminders completion") + controller.loadMoreReminders(limit: 2) { result in + let reminders = try? result.get() + XCTAssertNotNil(reminders) + exp.fulfill() + } + XCTAssertEqual(repositoryMock.loadReminders_query?.pagination.pageSize, 2) + + repositoryMock.loadReminders_completion?(.success(.init(reminders: [ + .mock(), + .mock(), + .mock() + ], next: .unique))) + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertFalse(controller.hasLoadedAllReminders) + } + + func test_loadMoreReminders_whenFailure() { + let exp = expectation(description: "loadMoreReminders completion") + controller.loadMoreReminders() { error in + XCTAssertNotNil(error) + exp.fulfill() + } + repositoryMock.loadReminders_completion?(.failure(ClientError())) + + wait(for: [exp], timeout: defaultTimeout) + } + + func test_loadMoreReminders_shouldUseNextCursorWhenMorePagesAvailable() { + let exp = expectation(description: "synchronize completion") + controller.synchronize { error in + XCTAssertNil(error) + exp.fulfill() + } + let nextCursor1 = "cursor1" + repositoryMock.loadReminders_completion?(.success( + .init(reminders: [.mock(), .mock()], next: nextCursor1)) + ) + wait(for: [exp], timeout: defaultTimeout) + + let expMoreReminders = expectation(description: "loadMoreReminders1 completion") + controller.loadMoreReminders() { result in + let reminders = try? result.get() + XCTAssertNotNil(reminders) + expMoreReminders.fulfill() + } + XCTAssertEqual(repositoryMock.loadReminders_query?.pagination.cursor, nextCursor1) + + let nextCursor2 = "cursor2" + repositoryMock.loadReminders_completion?(.success(.init( + reminders: [.mock(), .mock()], next: nextCursor2 + )) + ) + wait(for: [expMoreReminders], timeout: defaultTimeout) + + controller.loadMoreReminders() + XCTAssertEqual(repositoryMock.loadReminders_query?.pagination.cursor, nextCursor2) + } + + func test_observer_triggerDidChangeReminders_remindersHaveCorrectOrder() throws { + class DelegateMock: MessageReminderListControllerDelegate { + var reminders: [MessageReminder] = [] + let expectation = XCTestExpectation(description: "Did Change Reminders") + let expectedRemindersCount: Int + + init(expectedRemindersCount: Int) { + self.expectedRemindersCount = expectedRemindersCount + } + + func controller( + _ controller: MessageReminderListController, + didChangeReminders changes: [ListChange] + ) { + reminders = Array(controller.reminders) + guard expectedRemindersCount == reminders.count else { return } + expectation.fulfill() + } + } + + let delegate = DelegateMock(expectedRemindersCount: 3) + controller.synchronize() + controller.delegate = delegate + + try client.databaseContainer.writeSynchronously { session in + let date = Date.unique + let cid = ChannelId.unique + let messageId1 = MessageId.unique + let messageId2 = MessageId.unique + let messageId3 = MessageId.unique + + try session.saveCurrentUser(payload: .dummy(userId: .unique, role: .admin)) + try session.saveChannel(payload: .dummy(channel: .dummy(cid: cid))) + try session.saveMessage(payload: .dummy(messageId: messageId1), for: cid, syncOwnReactions: false, cache: nil) + try session.saveMessage(payload: .dummy(messageId: messageId2), for: cid, syncOwnReactions: false, cache: nil) + try session.saveMessage(payload: .dummy(messageId: messageId3), for: cid, syncOwnReactions: false, cache: nil) + + let reminders = [ + ReminderPayload( + channelCid: cid, + messageId: messageId1, + remindAt: date.addingTimeInterval(3), + createdAt: date, + updatedAt: date + ), + ReminderPayload( + channelCid: cid, + messageId: messageId2, + remindAt: date.addingTimeInterval(2), + createdAt: date, + updatedAt: date + ), + ReminderPayload( + channelCid: cid, + messageId: messageId3, + remindAt: date.addingTimeInterval(1), + createdAt: date, + updatedAt: date + ) + ] + + try reminders.forEach { + try session.saveReminder(payload: $0, cache: nil) + } + } + wait(for: [delegate.expectation], timeout: defaultTimeout) + XCTAssertEqual(controller.reminders.count, 3) + XCTAssertEqual(delegate.reminders.count, 3) + } +} + +// MARK: - Helpers + +extension MessageReminderListController_Tests { + func makeController( + query: MessageReminderListQuery = .init(), + repository: RemindersRepository? = nil, + observer: BackgroundListDatabaseObserver? = nil + ) -> MessageReminderListController { + MessageReminderListController( + query: query, + client: client, + environment: .init( + createMessageReminderListDatabaseObserver: { database, fetchRequest, itemCreator in + observer ?? BackgroundListDatabaseObserver( + database: database, + fetchRequest: fetchRequest, + itemCreator: itemCreator, + itemReuseKeyPaths: nil + ) + } + ) + ) + } +} + +private extension MessageReminder { + static func mock( + id: String = .unique, + remindAt: Date? = nil, + message: ChatMessage = .mock(), + channel: ChatChannel = .mockDMChannel(), + createdAt: Date = .unique, + updatedAt: Date = .unique + ) -> MessageReminder { + .init( + id: id, + remindAt: remindAt, + message: message, + channel: channel, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} From f0d95b7c876a208fb4e1753d2bd2cb7e70eede17 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Sat, 29 Mar 2025 01:00:20 +0000 Subject: [PATCH 30/42] Move reminder endpoints to a separate file --- .../Endpoints/MessageEndpoints.swift | 48 ---------- .../Endpoints/ReminderEndpoints.swift | 51 +++++++++++ StreamChat.xcodeproj/project.pbxproj | 10 +++ .../Endpoints/MessageEndpoints_Tests.swift | 82 ----------------- .../Endpoints/ReminderEndpoints_Tests.swift | 89 +++++++++++++++++++ 5 files changed, 150 insertions(+), 130 deletions(-) create mode 100644 Sources/StreamChat/APIClient/Endpoints/ReminderEndpoints.swift create mode 100644 Tests/StreamChatTests/APIClient/Endpoints/ReminderEndpoints_Tests.swift diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index 06c356c8d3f..e0a705226e2 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -94,54 +94,6 @@ extension Endpoint { } } -// MARK: - Reminder Endpoints - -extension Endpoint { - // Creates or updates a reminder for a message - static func createReminder(messageId: MessageId, request: ReminderRequestBody) -> Endpoint { - .init( - path: .reminder(messageId), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: request - ) - } - - // Updates an existing reminder for a message - static func updateReminder(messageId: MessageId, request: ReminderRequestBody) -> Endpoint { - .init( - path: .reminder(messageId), - method: .patch, - queryItems: nil, - requiresConnectionId: false, - body: request - ) - } - - // Deletes a reminder for a message - static func deleteReminder(messageId: MessageId) -> Endpoint { - .init( - path: .reminder(messageId), - method: .delete, - queryItems: nil, - requiresConnectionId: false, - body: nil - ) - } - - // Queries reminders with the provided parameters - static func queryReminders(query: MessageReminderListQuery) -> Endpoint { - .init( - path: .reminders, - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: query - ) - } -} - // MARK: - Helper data structures struct MessagePartialUpdateRequest: Encodable { diff --git a/Sources/StreamChat/APIClient/Endpoints/ReminderEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ReminderEndpoints.swift new file mode 100644 index 00000000000..12252a7b396 --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/ReminderEndpoints.swift @@ -0,0 +1,51 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension Endpoint { + // Creates or updates a reminder for a message + static func createReminder(messageId: MessageId, request: ReminderRequestBody) -> Endpoint { + .init( + path: .reminder(messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + } + + // Updates an existing reminder for a message + static func updateReminder(messageId: MessageId, request: ReminderRequestBody) -> Endpoint { + .init( + path: .reminder(messageId), + method: .patch, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + } + + // Deletes a reminder for a message + static func deleteReminder(messageId: MessageId) -> Endpoint { + .init( + path: .reminder(messageId), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: nil + ) + } + + // Queries reminders with the provided parameters + static func queryReminders(query: MessageReminderListQuery) -> Endpoint { + .init( + path: .reminders, + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: query + ) + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 541faef40ab..a6f5aa05bdf 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1679,6 +1679,9 @@ ADA83B452D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B432D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift */; }; ADA83B472D976D9C003B3928 /* MessageReminderListController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B462D976D9C003B3928 /* MessageReminderListController_Tests.swift */; }; ADA83B492D976ED7003B3928 /* MessageReminder_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */; }; + ADA83B4B2D977D59003B3928 /* ReminderEndpoints_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4A2D977D59003B3928 /* ReminderEndpoints_Tests.swift */; }; + ADA83B4D2D977D64003B3928 /* ReminderEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */; }; + ADA83B4E2D977D64003B3928 /* ReminderEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */; }; ADA8EBE928CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */; }; ADA8EBEB28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */; }; ADA9DB892BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA9DB882BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift */; }; @@ -4422,6 +4425,8 @@ ADA83B432D97511E003B3928 /* MessageReminderListController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReminderListController+Combine_Tests.swift"; sourceTree = ""; }; ADA83B462D976D9C003B3928 /* MessageReminderListController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminderListController_Tests.swift; sourceTree = ""; }; ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder_Mock.swift; sourceTree = ""; }; + ADA83B4A2D977D59003B3928 /* ReminderEndpoints_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderEndpoints_Tests.swift; sourceTree = ""; }; + ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderEndpoints.swift; sourceTree = ""; }; ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewUserMentionsHandler_Mock.swift; sourceTree = ""; }; ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContentViewDelegate_Mock.swift; sourceTree = ""; }; ADA9DB882BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReadDTO_Tests.swift; sourceTree = ""; }; @@ -5881,6 +5886,7 @@ 79877A122498E4EE00015F8B /* Endpoints */ = { isa = PBXGroup; children = ( + ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */, 88E26D7C2580F95300F55AB5 /* AttachmentEndpoints.swift */, 82C18FDB2C10C8E600C5283C /* BlockedUserPayload.swift */, 79877A132498E4EE00015F8B /* ChannelEndpoints.swift */, @@ -7130,6 +7136,7 @@ A364D09327D0BF330029857A /* Endpoints */ = { isa = PBXGroup; children = ( + ADA83B4A2D977D59003B3928 /* ReminderEndpoints_Tests.swift */, AD545E762D5BB3D6008FD399 /* DraftEndpoints_Tests.swift */, AD8C7C652BA46A4A00260715 /* AppEndpoints_Tests.swift */, 88381E8625825A240047A6A3 /* AttachmentEndpoints_Tests.swift */, @@ -11769,6 +11776,7 @@ 40789D1929F6AC500018C2BB /* AudioPlayerObserving.swift in Sources */, 84A43CAF26A9A25000302763 /* UnknownChannelEvent.swift in Sources */, C1B0B38327BFC08900C8207D /* EndpointPath+OfflineRequest.swift in Sources */, + ADA83B4E2D977D64003B3928 /* ReminderEndpoints.swift in Sources */, AD0CC0372BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */, 841BAA042BCE94F8000C73E4 /* QueryPollsRequestBody.swift in Sources */, C1EFF3F3285E459C0057B91B /* IdentifiableModel.swift in Sources */, @@ -11971,6 +11979,7 @@ C1EFF3F828633B5D0057B91B /* IdentifiableModel_Tests.swift in Sources */, 40789D4829F6C1DC0018C2BB /* StreamAppStateObserver_Tests.swift in Sources */, C143789727BE6D4800E23965 /* OfflineRequestsRepository_Tests.swift in Sources */, + ADA83B4B2D977D59003B3928 /* ReminderEndpoints_Tests.swift in Sources */, 84355D8B2AB3440E00FD5838 /* FileEndpoints_Tests.swift in Sources */, 84D5BC5B277B18AF00A65C75 /* PinnedMessagesQuery_Tests.swift in Sources */, DA4EE5BB252B69FD00CB26D4 /* UserListController+Combine_Tests.swift in Sources */, @@ -12727,6 +12736,7 @@ C121E8CA274544B100023E4C /* QueryOptions.swift in Sources */, C121E8CB274544B100023E4C /* ChannelQuery.swift in Sources */, C121E8CC274544B100023E4C /* Filter.swift in Sources */, + ADA83B4D2D977D64003B3928 /* ReminderEndpoints.swift in Sources */, C121E8CD274544B100023E4C /* Pagination.swift in Sources */, C121E8CE274544B100023E4C /* Sorting.swift in Sources */, C121E8CF274544B100023E4C /* ChannelListSortingKey.swift in Sources */, diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index 95afb22e283..35ea330b58f 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -189,86 +189,4 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) XCTAssertEqual("messages/\(messageId)/translate", endpoint.path.value) } - - // MARK: - Reminder Endpoints Tests - - func test_createReminder_buildsCorrectly() { - let messageId: MessageId = .unique - let remindAt = Date() - let request = ReminderRequestBody(remindAt: remindAt) - - let expectedEndpoint = Endpoint( - path: .reminder(messageId), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: request - ) - - let endpoint: Endpoint = .createReminder(messageId: messageId, request: request) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) - } - - func test_updateReminder_buildsCorrectly() { - let messageId: MessageId = .unique - let remindAt = Date() - let request = ReminderRequestBody(remindAt: remindAt) - - let expectedEndpoint = Endpoint( - path: .reminder(messageId), - method: .patch, - queryItems: nil, - requiresConnectionId: false, - body: request - ) - - let endpoint: Endpoint = .updateReminder(messageId: messageId, request: request) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) - } - - func test_deleteReminder_buildsCorrectly() { - let messageId: MessageId = .unique - - let expectedEndpoint = Endpoint( - path: .reminder(messageId), - method: .delete, - queryItems: nil, - requiresConnectionId: false, - body: nil - ) - - let endpoint: Endpoint = .deleteReminder(messageId: messageId) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) - } - - func test_queryReminders_buildsCorrectly() { - let query = MessageReminderListQuery( - filter: .equal(.cid, to: ChannelId.unique), - sort: [.init(key: .remindAt, isAscending: true)], - pageSize: 25 - ) - - let expectedEndpoint = Endpoint( - path: .reminders, - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: query - ) - - let endpoint: Endpoint = .queryReminders(query: query) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("reminders/query", endpoint.path.value) - } } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ReminderEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ReminderEndpoints_Tests.swift new file mode 100644 index 00000000000..9a8f0dbca76 --- /dev/null +++ b/Tests/StreamChatTests/APIClient/Endpoints/ReminderEndpoints_Tests.swift @@ -0,0 +1,89 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReminderEndpoints_Tests: XCTestCase { + func test_createReminder_buildsCorrectly() { + let messageId: MessageId = .unique + let remindAt = Date() + let request = ReminderRequestBody(remindAt: remindAt) + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + + let endpoint: Endpoint = .createReminder(messageId: messageId, request: request) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_updateReminder_buildsCorrectly() { + let messageId: MessageId = .unique + let remindAt = Date() + let request = ReminderRequestBody(remindAt: remindAt) + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .patch, + queryItems: nil, + requiresConnectionId: false, + body: request + ) + + let endpoint: Endpoint = .updateReminder(messageId: messageId, request: request) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_deleteReminder_buildsCorrectly() { + let messageId: MessageId = .unique + + let expectedEndpoint = Endpoint( + path: .reminder(messageId), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: nil + ) + + let endpoint: Endpoint = .deleteReminder(messageId: messageId) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reminders", endpoint.path.value) + } + + func test_queryReminders_buildsCorrectly() { + let query = MessageReminderListQuery( + filter: .equal(.cid, to: ChannelId.unique), + sort: [.init(key: .remindAt, isAscending: true)], + pageSize: 25 + ) + + let expectedEndpoint = Endpoint( + path: .reminders, + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: query + ) + + let endpoint: Endpoint = .queryReminders(query: query) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("reminders/query", endpoint.path.value) + } +} From 61161db8dc8e01cae194f1f79031c6360e6dc122 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Sat, 29 Mar 2025 01:15:42 +0000 Subject: [PATCH 31/42] Create Reminder Payloads file --- .../Endpoints/Payloads/MessagePayloads.swift | 65 ---------- .../Endpoints/Payloads/ReminderPayloads.swift | 70 +++++++++++ StreamChat.xcodeproj/project.pbxproj | 10 ++ .../Payloads/MessagePayloads_Tests.swift | 105 ---------------- .../Payloads/ReminderPayloads_Tests.swift | 112 ++++++++++++++++++ 5 files changed, 192 insertions(+), 170 deletions(-) create mode 100644 Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift create mode 100644 Tests/StreamChatTests/APIClient/Endpoints/Payloads/ReminderPayloads_Tests.swift diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 8a4e04db132..4cd1add2fe6 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift @@ -386,68 +386,3 @@ public struct Command: Codable, Hashable { self.args = args } } - -/// An object describing a reminder JSON payload. -struct ReminderPayload: Decodable { - let channelCid: ChannelId - let channel: ChannelDetailPayload? - let messageId: MessageId - let message: MessagePayload? - let remindAt: Date? - let createdAt: Date - let updatedAt: Date - - init( - channelCid: ChannelId, - messageId: MessageId, - message: MessagePayload? = nil, - channel: ChannelDetailPayload? = nil, - remindAt: Date?, - createdAt: Date, - updatedAt: Date - ) { - self.channelCid = channelCid - self.messageId = messageId - self.message = message - self.channel = channel - self.remindAt = remindAt - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - enum CodingKeys: String, CodingKey { - case channelCid = "channel_cid" - case messageId = "message_id" - case message - case channel - case remindAt = "remind_at" - case createdAt = "created_at" - case updatedAt = "updated_at" - } -} - -/// A request body for creating or updating a reminder -struct ReminderRequestBody: Encodable { - let remindAt: Date? - - init( - remindAt: Date? - ) { - self.remindAt = remindAt - } - - enum CodingKeys: String, CodingKey { - case remindAt = "remind_at" - } -} - -/// A response containing a list of reminders -struct RemindersQueryPayload: Decodable { - let reminders: [ReminderPayload] - let next: String? -} - -/// A response containing a single reminder -struct ReminderResponsePayload: Decodable { - let reminder: ReminderPayload -} diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift new file mode 100644 index 00000000000..cfad4ceeb1b --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift @@ -0,0 +1,70 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// An object describing a reminder JSON payload. +struct ReminderPayload: Decodable { + let channelCid: ChannelId + let channel: ChannelDetailPayload? + let messageId: MessageId + let message: MessagePayload? + let remindAt: Date? + let createdAt: Date + let updatedAt: Date + + init( + channelCid: ChannelId, + messageId: MessageId, + message: MessagePayload? = nil, + channel: ChannelDetailPayload? = nil, + remindAt: Date?, + createdAt: Date, + updatedAt: Date + ) { + self.channelCid = channelCid + self.messageId = messageId + self.message = message + self.channel = channel + self.remindAt = remindAt + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + enum CodingKeys: String, CodingKey { + case channelCid = "channel_cid" + case messageId = "message_id" + case message + case channel + case remindAt = "remind_at" + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +/// A request body for creating or updating a reminder +struct ReminderRequestBody: Encodable { + let remindAt: Date? + + init( + remindAt: Date? + ) { + self.remindAt = remindAt + } + + enum CodingKeys: String, CodingKey { + case remindAt = "remind_at" + } +} + +/// A response containing a list of reminders +struct RemindersQueryPayload: Decodable { + let reminders: [ReminderPayload] + let next: String? +} + +/// A response containing a single reminder +struct ReminderResponsePayload: Decodable { + let reminder: ReminderPayload +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index a6f5aa05bdf..ee0353001b8 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1682,6 +1682,9 @@ ADA83B4B2D977D59003B3928 /* ReminderEndpoints_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4A2D977D59003B3928 /* ReminderEndpoints_Tests.swift */; }; ADA83B4D2D977D64003B3928 /* ReminderEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */; }; ADA83B4E2D977D64003B3928 /* ReminderEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */; }; + ADA83B502D978050003B3928 /* ReminderPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4F2D978050003B3928 /* ReminderPayloads.swift */; }; + ADA83B512D978050003B3928 /* ReminderPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B4F2D978050003B3928 /* ReminderPayloads.swift */; }; + ADA83B532D97805A003B3928 /* ReminderPayloads_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA83B522D97805A003B3928 /* ReminderPayloads_Tests.swift */; }; ADA8EBE928CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */; }; ADA8EBEB28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */; }; ADA9DB892BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA9DB882BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift */; }; @@ -4427,6 +4430,8 @@ ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReminder_Mock.swift; sourceTree = ""; }; ADA83B4A2D977D59003B3928 /* ReminderEndpoints_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderEndpoints_Tests.swift; sourceTree = ""; }; ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderEndpoints.swift; sourceTree = ""; }; + ADA83B4F2D978050003B3928 /* ReminderPayloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderPayloads.swift; sourceTree = ""; }; + ADA83B522D97805A003B3928 /* ReminderPayloads_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderPayloads_Tests.swift; sourceTree = ""; }; ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewUserMentionsHandler_Mock.swift; sourceTree = ""; }; ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContentViewDelegate_Mock.swift; sourceTree = ""; }; ADA9DB882BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReadDTO_Tests.swift; sourceTree = ""; }; @@ -5825,6 +5830,7 @@ 79682C4724BF37550071578E /* Payloads */ = { isa = PBXGroup; children = ( + ADA83B4F2D978050003B3928 /* ReminderPayloads.swift */, ADF2BBEA2B9B622B0069D467 /* AppSettingsPayload.swift */, DA9985ED24E175AA000E9885 /* ChannelCodingKeys.swift */, DAFAD6A224DD8E1A0043ED06 /* ChannelEditDetailPayload.swift */, @@ -7164,6 +7170,7 @@ A364D09427D0BF3A0029857A /* Payloads */ = { isa = PBXGroup; children = ( + ADA83B522D97805A003B3928 /* ReminderPayloads_Tests.swift */, AD545E6A2D5650B5008FD399 /* DraftPayloads_Tests.swift */, AD8C7C622BA464E600260715 /* AppSettingsPayload_Tests.swift */, AD6E32952BBB10890073831B /* ThreadListPayload_Tests.swift */, @@ -11440,6 +11447,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ADA83B502D978050003B3928 /* ReminderPayloads.swift in Sources */, 4F97F2672BA83146001C4D66 /* UserList.swift in Sources */, 82BE0ACD2C009A17008DA9DC /* BlockedUserDetails.swift in Sources */, DA8407032524F7E6005A0F62 /* UserListUpdater.swift in Sources */, @@ -12151,6 +12159,7 @@ C186BFAA27AA979B0099CCA6 /* SyncRepository_Tests.swift in Sources */, DAE566F12500F3C800E39431 /* CurrentUserController+SwiftUI_Tests.swift in Sources */, F69C4BC424F664A700A3D740 /* EventNotificationCenter_Tests.swift in Sources */, + ADA83B532D97805A003B3928 /* ReminderPayloads_Tests.swift in Sources */, 4FD94FC52BCD5EF00084FEDF /* ConnectedUser_Tests.swift in Sources */, AD0AD6C02A25140A00CB96CB /* MessagesPaginationState_Tests.swift in Sources */, C12DBE612A67E2D60045D9F0 /* SortingValue_Tests.swift in Sources */, @@ -12702,6 +12711,7 @@ ADE40044291B1A510000C98B /* AttachmentUploader.swift in Sources */, 841BAA022BCE9394000C73E4 /* UpdatePollRequestBody.swift in Sources */, C121E8B9274544B000023E4C /* MessageController.swift in Sources */, + ADA83B512D978050003B3928 /* ReminderPayloads.swift in Sources */, AD483B972A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift in Sources */, 841BA9FF2BCE8E6D000C73E4 /* CreatePollRequestBody.swift in Sources */, C121E8BA274544B100023E4C /* MessageController+SwiftUI.swift in Sources */, diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift index 817fcf43960..65e35c8dc8a 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift @@ -259,108 +259,3 @@ final class MessageReactionsPayload_Tests: XCTestCase { XCTAssertTrue(payload.reactions.count == 2) } } - -final class ReminderPayload_Tests: XCTestCase { - let reminderJSON = XCTestCase.mockData(fromJSONFile: "ReminderPayload") - - func test_reminderPayload_isSerialized() throws { - let payload = try JSONDecoder.default.decode(ReminderPayload.self, from: reminderJSON) - - // Test basic properties - XCTAssertEqual(payload.channelCid.rawValue, "messaging:26D82FB1-5") - XCTAssertEqual(payload.messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") - XCTAssertNil(payload.remindAt) // Updated to nil as per new JSON - XCTAssertEqual(payload.createdAt, "2025-03-19T00:38:38.697482729Z".toDate()) - XCTAssertEqual(payload.updatedAt, "2025-03-19T00:38:38.697482729Z".toDate()) - - // Test embedded message - XCTAssertNotNil(payload.message) - XCTAssertEqual(payload.message?.id, "lando_calrissian-8tnV2qn0umMogef2WjR4k") - XCTAssertEqual(payload.message?.text, "4") - XCTAssertEqual(payload.message?.type.rawValue, "regular") - XCTAssertEqual(payload.message?.user.id, "lando_calrissian") - XCTAssertEqual(payload.message?.createdAt, "2025-03-04T14:33:10.628163Z".toDate()) - XCTAssertEqual(payload.message?.updatedAt, "2025-03-04T14:33:10.628163Z".toDate()) - - // Test channel properties (new in updated JSON) - XCTAssertNotNil(payload.channel) - XCTAssertEqual(payload.channel?.cid.rawValue, "messaging:26D82FB1-5") - XCTAssertEqual(payload.channel?.name, "Yo") - } -} - -final class ReminderResponsePayload_Tests: XCTestCase { - func test_isSerialized() throws { - // Create a JSON representation of a ReminderResponsePayload - // with the updated structure including duration - let reminderResponseJSON = """ - { - "duration": "30.74ms", - "reminder": { - "channel_cid": "messaging:26D82FB1-5", - "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", - "remind_at": null, - "created_at": "2025-03-19T00:38:38.697482729Z", - "updated_at": "2025-03-19T00:38:38.697482729Z", - "user_id": "han_solo" - } - } - """.data(using: .utf8)! - - let payload = try JSONDecoder.default.decode(ReminderResponsePayload.self, from: reminderResponseJSON) - - XCTAssertEqual(payload.reminder.channelCid.rawValue, "messaging:26D82FB1-5") - XCTAssertEqual(payload.reminder.messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") - XCTAssertNil(payload.reminder.remindAt) - XCTAssertEqual(payload.reminder.createdAt, "2025-03-19T00:38:38.697482729Z".toDate()) - XCTAssertEqual(payload.reminder.updatedAt, "2025-03-19T00:38:38.697482729Z".toDate()) - } -} - -final class RemindersQueryPayload_Tests: XCTestCase { - func test_isSerialized() throws { - // Create a JSON representation of a RemindersQueryPayload with updated structure - let remindersQueryJSON = """ - { - "duration": "30.74ms", - "reminders": [ - { - "channel_cid": "messaging:26D82FB1-5", - "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", - "remind_at": null, - "created_at": "2025-03-19T00:38:38.697482729Z", - "updated_at": "2025-03-19T00:38:38.697482729Z", - "user_id": "han_solo" - }, - { - "channel_cid": "messaging:456", - "message_id": "message-456", - "remind_at": "2023-02-01T12:00:00.000Z", - "created_at": "2022-02-03T00:00:00.000Z", - "updated_at": "2022-02-03T00:00:00.000Z", - "user_id": "luke_skywalker" - } - ], - "next": "next-page-token", - } - """.data(using: .utf8)! - - let payload = try JSONDecoder.default.decode(RemindersQueryPayload.self, from: remindersQueryJSON) - - // Verify the count of reminders - XCTAssertEqual(payload.reminders.count, 2) - - // Verify pagination tokens - XCTAssertEqual(payload.next, "next-page-token") - - // Verify first reminder details - XCTAssertEqual(payload.reminders[0].channelCid.rawValue, "messaging:26D82FB1-5") - XCTAssertEqual(payload.reminders[0].messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") - XCTAssertNil(payload.reminders[0].remindAt) - - // Verify second reminder details - XCTAssertEqual(payload.reminders[1].channelCid.rawValue, "messaging:456") - XCTAssertEqual(payload.reminders[1].messageId, "message-456") - XCTAssertEqual(payload.reminders[1].remindAt, "2023-02-01T12:00:00.000Z".toDate()) - } -} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ReminderPayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ReminderPayloads_Tests.swift new file mode 100644 index 00000000000..11372e50e62 --- /dev/null +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ReminderPayloads_Tests.swift @@ -0,0 +1,112 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReminderPayload_Tests: XCTestCase { + let reminderJSON = XCTestCase.mockData(fromJSONFile: "ReminderPayload") + + func test_reminderPayload_isSerialized() throws { + let payload = try JSONDecoder.default.decode(ReminderPayload.self, from: reminderJSON) + + // Test basic properties + XCTAssertEqual(payload.channelCid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertNil(payload.remindAt) // Updated to nil as per new JSON + XCTAssertEqual(payload.createdAt, "2025-03-19T00:38:38.697482729Z".toDate()) + XCTAssertEqual(payload.updatedAt, "2025-03-19T00:38:38.697482729Z".toDate()) + + // Test embedded message + XCTAssertNotNil(payload.message) + XCTAssertEqual(payload.message?.id, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertEqual(payload.message?.text, "4") + XCTAssertEqual(payload.message?.type.rawValue, "regular") + XCTAssertEqual(payload.message?.user.id, "lando_calrissian") + XCTAssertEqual(payload.message?.createdAt, "2025-03-04T14:33:10.628163Z".toDate()) + XCTAssertEqual(payload.message?.updatedAt, "2025-03-04T14:33:10.628163Z".toDate()) + + // Test channel properties (new in updated JSON) + XCTAssertNotNil(payload.channel) + XCTAssertEqual(payload.channel?.cid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.channel?.name, "Yo") + } +} + +final class ReminderResponsePayload_Tests: XCTestCase { + func test_isSerialized() throws { + // Create a JSON representation of a ReminderResponsePayload + // with the updated structure including duration + let reminderResponseJSON = """ + { + "duration": "30.74ms", + "reminder": { + "channel_cid": "messaging:26D82FB1-5", + "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", + "remind_at": null, + "created_at": "2025-03-19T00:38:38.697482729Z", + "updated_at": "2025-03-19T00:38:38.697482729Z", + "user_id": "han_solo" + } + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(ReminderResponsePayload.self, from: reminderResponseJSON) + + XCTAssertEqual(payload.reminder.channelCid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.reminder.messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertNil(payload.reminder.remindAt) + XCTAssertEqual(payload.reminder.createdAt, "2025-03-19T00:38:38.697482729Z".toDate()) + XCTAssertEqual(payload.reminder.updatedAt, "2025-03-19T00:38:38.697482729Z".toDate()) + } +} + +final class RemindersQueryPayload_Tests: XCTestCase { + func test_isSerialized() throws { + // Create a JSON representation of a RemindersQueryPayload with updated structure + let remindersQueryJSON = """ + { + "duration": "30.74ms", + "reminders": [ + { + "channel_cid": "messaging:26D82FB1-5", + "message_id": "lando_calrissian-8tnV2qn0umMogef2WjR4k", + "remind_at": null, + "created_at": "2025-03-19T00:38:38.697482729Z", + "updated_at": "2025-03-19T00:38:38.697482729Z", + "user_id": "han_solo" + }, + { + "channel_cid": "messaging:456", + "message_id": "message-456", + "remind_at": "2023-02-01T12:00:00.000Z", + "created_at": "2022-02-03T00:00:00.000Z", + "updated_at": "2022-02-03T00:00:00.000Z", + "user_id": "luke_skywalker" + } + ], + "next": "next-page-token" + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(RemindersQueryPayload.self, from: remindersQueryJSON) + + // Verify the count of reminders + XCTAssertEqual(payload.reminders.count, 2) + + // Verify pagination tokens + XCTAssertEqual(payload.next, "next-page-token") + + // Verify first reminder details + XCTAssertEqual(payload.reminders[0].channelCid.rawValue, "messaging:26D82FB1-5") + XCTAssertEqual(payload.reminders[0].messageId, "lando_calrissian-8tnV2qn0umMogef2WjR4k") + XCTAssertNil(payload.reminders[0].remindAt) + + // Verify second reminder details + XCTAssertEqual(payload.reminders[1].channelCid.rawValue, "messaging:456") + XCTAssertEqual(payload.reminders[1].messageId, "message-456") + XCTAssertEqual(payload.reminders[1].remindAt, "2023-02-01T12:00:00.000Z".toDate()) + } +} From daf382f0e5964a02d98c3da00736d6b09ee5b3a3 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Sat, 29 Mar 2025 01:18:30 +0000 Subject: [PATCH 32/42] Move reminder payloads to classes to reduce SDK size --- .../APIClient/Endpoints/Payloads/ReminderPayloads.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift index cfad4ceeb1b..db2befe7244 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift @@ -5,7 +5,7 @@ import Foundation /// An object describing a reminder JSON payload. -struct ReminderPayload: Decodable { +class ReminderPayload: Decodable { let channelCid: ChannelId let channel: ChannelDetailPayload? let messageId: MessageId @@ -44,7 +44,7 @@ struct ReminderPayload: Decodable { } /// A request body for creating or updating a reminder -struct ReminderRequestBody: Encodable { +class ReminderRequestBody: Encodable { let remindAt: Date? init( @@ -59,12 +59,12 @@ struct ReminderRequestBody: Encodable { } /// A response containing a list of reminders -struct RemindersQueryPayload: Decodable { +class RemindersQueryPayload: Decodable { let reminders: [ReminderPayload] let next: String? } /// A response containing a single reminder -struct ReminderResponsePayload: Decodable { +class ReminderResponsePayload: Decodable { let reminder: ReminderPayload } From 5e54db123ff0715929166fe5862ee7a5de9866be Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Sat, 29 Mar 2025 01:23:35 +0000 Subject: [PATCH 33/42] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d66b116ce0..eb4dfe709bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +## StreamChat +### ✅ Added +- Add new `Filter.isNil` to make it easier to query by nil values [#3623](https://github.com/GetStream/stream-chat-swift/pull/3623) +- Add Message Reminders [#3623](https://github.com/GetStream/stream-chat-swift/pull/3623) + - Add `ChatMessageController.createReminder()` + - Add `ChatMessageController.updateReminder()` + - Add `ChatMessageController.deleteReminder()` + - Add `MessageReminderListController` and `MessageReminderListQuery` ### StreamChatUI ### 🐞 Fixed From a7186b37c6cfb9aae82e0e5f250c7e1636938f97 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Sat, 29 Mar 2025 01:31:31 +0000 Subject: [PATCH 34/42] Remove unnecessary notifications import --- DemoApp/Screens/DemoAppTabBarController.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/DemoApp/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 7f329470ae9..ec776b4baa5 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -5,7 +5,6 @@ import StreamChat import StreamChatUI import UIKit -import UserNotifications class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, MessageReminderListControllerDelegate { let channelListVC: UIViewController @@ -17,9 +16,6 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele // Events controller for listening to chat events private var eventsController: EventsController! - - // User notification center for displaying local notifications - private let notificationCenter = UNUserNotificationCenter.current() init( channelListVC: UIViewController, From 1a3ef47f7ccf661ba56cba12694f441672abfe84 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Mon, 31 Mar 2025 16:31:48 +0100 Subject: [PATCH 35/42] Fix unit tests by missing inits of payloads --- .../APIClient/Endpoints/Payloads/ReminderPayloads.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift index db2befe7244..b05af6b755b 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift @@ -62,9 +62,18 @@ class ReminderRequestBody: Encodable { class RemindersQueryPayload: Decodable { let reminders: [ReminderPayload] let next: String? + + init(reminders: [ReminderPayload], next: String?) { + self.reminders = reminders + self.next = next + } } /// A response containing a single reminder class ReminderResponsePayload: Decodable { let reminder: ReminderPayload + + init(reminder: ReminderPayload) { + self.reminder = reminder + } } From ba98cd84c43ae38d2474b0f10634f77ff4996ce4 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Tue, 1 Apr 2025 23:52:08 +0100 Subject: [PATCH 36/42] Remove `messageId` from FilterKey since it is not really useful at the moment --- Sources/StreamChat/Query/MessageReminderListQuery.swift | 4 ---- .../Query/MessageReminderListQuery_Tests.swift | 1 - 2 files changed, 5 deletions(-) diff --git a/Sources/StreamChat/Query/MessageReminderListQuery.swift b/Sources/StreamChat/Query/MessageReminderListQuery.swift index 39afba99572..5dfa8f6922a 100644 --- a/Sources/StreamChat/Query/MessageReminderListQuery.swift +++ b/Sources/StreamChat/Query/MessageReminderListQuery.swift @@ -20,10 +20,6 @@ public extension FilterKey where Scope: AnyMessageReminderListFilterScope { valueMapper: { $0.rawValue } ) } - /// A filter key for matching the `message_id` value. - /// Supported operators: `in`, `equal` - static var messageId: FilterKey { .init(rawValue: "message_id", keyPathString: #keyPath(MessageReminderDTO.id)) } - /// A filter key for matching the `remind_at` value. /// Supported operators: `equal`, `greaterThan`, `lessThan`, `greaterOrEqual`, `lessOrEqual` static var remindAt: FilterKey { .init(rawValue: "remind_at", keyPathString: #keyPath(MessageReminderDTO.remindAt)) } diff --git a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift index a1dd35e58b8..1985c25d30e 100644 --- a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift +++ b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift @@ -99,7 +99,6 @@ final class MessageReminderListQuery_Tests: XCTestCase { func test_filterKeys() { // Test the filter keys for proper values XCTAssertEqual(FilterKey.cid.rawValue, "channel_cid") - XCTAssertEqual(FilterKey.messageId.rawValue, "message_id") XCTAssertEqual(FilterKey.remindAt.rawValue, "remind_at") XCTAssertEqual(FilterKey.createdAt.rawValue, "created_at") } From 8c7062dd1523f3e1ebf121a1060809f1e49793e9 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Apr 2025 01:54:36 +0100 Subject: [PATCH 37/42] Fix reminder lists not updating when due date is reached by the fact that the current date is now an old date --- DemoApp/Screens/DemoReminderListVC.swift | 55 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index 914f885907b..20aa574857f 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -22,6 +22,8 @@ class DemoReminderListVC: UIViewController, ThemeProvider { private lazy var laterRemindersController = FilterOption.later.makeController(client: currentUserController.client) private lazy var overdueRemindersController = FilterOption.overdue.makeController(client: currentUserController.client) + private lazy var eventsController = currentUserController.client.eventsController() + // Timer for refreshing due dates on cells private var refreshTimer: Timer? @@ -155,7 +157,9 @@ class DemoReminderListVC: UIViewController, ThemeProvider { super.viewDidLoad() title = "Reminders" - + + eventsController.delegate = self + userAvatarView.controller = currentUserController userAvatarView.addTarget(self, action: #selector(didTapOnCurrentUserAvatar), for: .touchUpInside) userAvatarView.translatesAutoresizingMaskIntoConstraints = false @@ -354,16 +358,14 @@ class DemoReminderListVC: UIViewController, ThemeProvider { case .later: activeController = laterRemindersController } - + activeController.delegate = self + // Only load reminders if this controller hasn't loaded any yet if activeController.reminders.isEmpty && !activeController.hasLoadedAllReminders { loadReminders() } else { // Otherwise just update the UI with existing data - reminders = Array(activeController.reminders) - tableView.reloadData() - updateEmptyStateMessage() - emptyStateView.isHidden = !reminders.isEmpty + updateRemindersData() } } @@ -454,22 +456,51 @@ class DemoReminderListVC: UIViewController, ThemeProvider { alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) } + + private func updateRemindersData() { + reminders = Array(activeController.reminders) + tableView.reloadData() + updateEmptyStateMessage() + emptyStateView.isHidden = !reminders.isEmpty + } } // MARK: - MessageReminderListControllerDelegate -extension DemoReminderListVC: MessageReminderListControllerDelegate { +extension DemoReminderListVC: MessageReminderListControllerDelegate, EventsControllerDelegate { func controller( _ controller: MessageReminderListController, didChangeReminders changes: [ListChange] ) { // Only update UI if this is the active controller guard controller === activeController else { return } - - reminders = Array(controller.reminders) - tableView.reloadData() - updateEmptyStateMessage() - emptyStateView.isHidden = !reminders.isEmpty + updateRemindersData() + } + + func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + if event is ReminderDueEvent { + updateReminderListsWithNewNowDate() + } + } + + /// Update the reminder lists with the new current date. + /// When the controllers are created, they use the current date to query the reminders. + /// When a reminder is due, we need to re-create the queries with the new current date. + /// Otherwise, the reminders will not be updated since the current date will be outdated. + private func updateReminderListsWithNewNowDate() { + upcomingRemindersController = FilterOption.upcoming.makeController(client: currentUserController.client) + overdueRemindersController = FilterOption.overdue.makeController(client: currentUserController.client) + scheduledRemindersController = FilterOption.scheduled.makeController(client: currentUserController.client) + if selectedFilter == .upcoming { + activeController = upcomingRemindersController + } else if selectedFilter == .overdue { + activeController = overdueRemindersController + } else if selectedFilter == .scheduled { + activeController = scheduledRemindersController + } else { + return + } + updateRemindersData() } } From 95072ac30f608de38c211cb51c78232726b72c4f Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Apr 2025 02:05:32 +0100 Subject: [PATCH 38/42] Fix forgotten setting of a delegate --- DemoApp/Screens/DemoReminderListVC.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index 20aa574857f..9510eec28b5 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -500,6 +500,7 @@ extension DemoReminderListVC: MessageReminderListControllerDelegate, EventsContr } else { return } + activeController.delegate = self updateRemindersData() } } From e7ef1d33b42def396669404b8889050a00c81132 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Apr 2025 12:37:10 +0100 Subject: [PATCH 39/42] Remove unnecessary available iOS 13 macros --- .../MessageReminderListController+Combine.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift index 93663f28ba2..17c26ca55c0 100644 --- a/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift +++ b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift @@ -5,7 +5,6 @@ import Combine import Foundation -@available(iOS 13, *) extension MessageReminderListController { /// A publisher emitting a new value every time the state of the controller changes. public var statePublisher: AnyPublisher { @@ -34,7 +33,6 @@ extension MessageReminderListController { } } -@available(iOS 13, *) extension MessageReminderListController.BasePublishers: MessageReminderListControllerDelegate { func controller(_ controller: DataController, didChangeState state: DataController.State) { self.state.send(state) From 9e12edb3936f3c61064e3ddc81b44f8e1b3076a2 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Apr 2025 12:41:53 +0100 Subject: [PATCH 40/42] Add "Message" prefix to Reminder Events --- DemoApp/Screens/DemoReminderListVC.swift | 2 +- .../WebSocketClient/Events/EventType.swift | 16 ++++++++-------- .../WebSocketClient/Events/ReminderEvents.swift | 16 ++++++++-------- .../ReminderUpdaterMiddleware_Tests.swift | 8 ++++---- .../Events/ReminderEvents_Tests.swift | 8 ++++---- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index 9510eec28b5..2c28e9ba30a 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -478,7 +478,7 @@ extension DemoReminderListVC: MessageReminderListControllerDelegate, EventsContr } func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { - if event is ReminderDueEvent { + if event is MessageReminderDueEvent { updateReminderListsWithNewNowDate() } } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventType.swift b/Sources/StreamChat/WebSocketClient/Events/EventType.swift index b60029a789c..504ef2e2502 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventType.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventType.swift @@ -166,16 +166,16 @@ public extension EventType { // MARK: - Reminders /// When a reminder was created. - static let reminderCreated: Self = "reminder.created" + static let messageReminderCreated: Self = "reminder.created" /// When a reminder was updated. - static let reminderUpdated: Self = "reminder.updated" + static let messageReminderUpdated: Self = "reminder.updated" /// When a reminder was deleted. - static let reminderDeleted: Self = "reminder.deleted" + static let messageReminderDeleted: Self = "reminder.deleted" /// When a reminder is due. - static let notificationReminderDue: Self = "notification.reminder_due" + static let messageReminderDue: Self = "notification.reminder_due" } extension EventType { @@ -246,10 +246,10 @@ extension EventType { case .aiTypingIndicatorStop: return try AIIndicatorStopEventDTO(from: response) case .draftUpdated: return try DraftUpdatedEventDTO(from: response) case .draftDeleted: return try DraftDeletedEventDTO(from: response) - case .reminderCreated: return try ReminderCreatedEventDTO(from: response) - case .reminderUpdated: return try ReminderUpdatedEventDTO(from: response) - case .reminderDeleted: return try ReminderDeletedEventDTO(from: response) - case .notificationReminderDue: return try ReminderDueNotificationEventDTO(from: response) + case .messageReminderCreated: return try ReminderCreatedEventDTO(from: response) + case .messageReminderUpdated: return try ReminderUpdatedEventDTO(from: response) + case .messageReminderDeleted: return try ReminderDeletedEventDTO(from: response) + case .messageReminderDue: return try ReminderDueNotificationEventDTO(from: response) default: if response.cid == nil { throw ClientError.UnknownUserEvent(response.eventType) diff --git a/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift index 2c8ee81a09b..865d6f0e12a 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift @@ -5,7 +5,7 @@ import Foundation /// Triggered when a message reminder is created. -public class ReminderCreatedEvent: Event { +public class MessageReminderCreatedEvent: Event { /// The message ID associated with the reminder. public let messageId: MessageId @@ -44,7 +44,7 @@ class ReminderCreatedEventDTO: EventDTO { let reminderModel = try? reminderDTO.asModel() else { return nil } - return ReminderCreatedEvent( + return MessageReminderCreatedEvent( messageId: messageId, reminder: reminderModel, createdAt: createdAt @@ -53,7 +53,7 @@ class ReminderCreatedEventDTO: EventDTO { } /// Triggered when a message reminder is updated. -public class ReminderUpdatedEvent: Event { +public class MessageReminderUpdatedEvent: Event { /// The message ID associated with the reminder. public let messageId: MessageId @@ -92,7 +92,7 @@ class ReminderUpdatedEventDTO: EventDTO { let reminderModel = try? reminderDTO.asModel() else { return nil } - return ReminderUpdatedEvent( + return MessageReminderUpdatedEvent( messageId: messageId, reminder: reminderModel, createdAt: createdAt @@ -101,7 +101,7 @@ class ReminderUpdatedEventDTO: EventDTO { } /// Triggered when a message reminder is deleted. -public class ReminderDeletedEvent: Event { +public class MessageReminderDeletedEvent: Event { /// The message ID associated with the reminder. public let messageId: MessageId @@ -144,7 +144,7 @@ class ReminderDeletedEventDTO: EventDTO { // Delete the reminder from the database session.deleteReminder(messageId: messageId) - return ReminderDeletedEvent( + return MessageReminderDeletedEvent( messageId: messageId, reminder: reminderModel, createdAt: createdAt @@ -153,7 +153,7 @@ class ReminderDeletedEventDTO: EventDTO { } /// Triggered when a reminder is due and a notification should be shown. -public class ReminderDueEvent: Event { +public class MessageReminderDueEvent: Event { /// The message ID associated with the reminder. public let messageId: MessageId @@ -192,7 +192,7 @@ class ReminderDueNotificationEventDTO: EventDTO { let reminderModel = try? reminderDTO.asModel() else { return nil } - return ReminderDueEvent( + return MessageReminderDueEvent( messageId: messageId, reminder: reminderModel, createdAt: createdAt diff --git a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift index a2d6b76af7e..52042d05fda 100644 --- a/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift @@ -35,7 +35,7 @@ final class ReminderUpdaterMiddleware_Tests: XCTestCase { ) let eventPayload = EventPayload( - eventType: .reminderCreated, + eventType: .messageReminderCreated, createdAt: Date(), messageId: messageId, reminder: reminderPayload @@ -102,7 +102,7 @@ final class ReminderUpdaterMiddleware_Tests: XCTestCase { ) let eventPayload = EventPayload( - eventType: .reminderUpdated, + eventType: .messageReminderUpdated, createdAt: Date(), messageId: messageId, reminder: updatedReminderPayload @@ -148,7 +148,7 @@ final class ReminderUpdaterMiddleware_Tests: XCTestCase { // Create due notification payload (same as the original in this case) let eventPayload = EventPayload( - eventType: .notificationReminderDue, + eventType: .messageReminderDue, createdAt: Date(), messageId: messageId, reminder: initialReminderPayload @@ -195,7 +195,7 @@ final class ReminderUpdaterMiddleware_Tests: XCTestCase { // Create delete event payload let eventPayload = EventPayload( - eventType: .reminderDeleted, + eventType: .messageReminderDeleted, createdAt: Date(), messageId: messageId, reminder: reminderPayload diff --git a/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift b/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift index bb6560d2bb7..9534d3f1f48 100644 --- a/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/Events/ReminderEvents_Tests.swift @@ -46,7 +46,7 @@ final class ReminderEvents_Tests: XCTestCase { _ = try session.saveChannel(payload: .dummy(cid: channelId), query: nil, cache: nil) _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: channelId, cache: nil) - let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderCreatedEvent) + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? MessageReminderCreatedEvent) XCTAssertEqual(domainEvent.messageId, "f7af18f2-0a46-431d-8901-19c105de7f0a") XCTAssertEqual(domainEvent.reminder.id, "f7af18f2-0a46-431d-8901-19c105de7f0a") XCTAssertEqual(domainEvent.reminder.channel.cid, channelId) @@ -83,7 +83,7 @@ final class ReminderEvents_Tests: XCTestCase { _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: cid, cache: nil) - let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderUpdatedEvent) + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? MessageReminderUpdatedEvent) XCTAssertEqual(domainEvent.messageId, messageId) XCTAssertEqual(domainEvent.reminder.id, messageId) XCTAssertEqual(domainEvent.reminder.channel.cid, cid) @@ -121,7 +121,7 @@ final class ReminderEvents_Tests: XCTestCase { _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: cid, cache: nil) - let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderDeletedEvent) + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? MessageReminderDeletedEvent) XCTAssertEqual(domainEvent.messageId, messageId) XCTAssertEqual(domainEvent.reminder.id, messageId) XCTAssertEqual(domainEvent.reminder.channel.cid, cid) @@ -159,7 +159,7 @@ final class ReminderEvents_Tests: XCTestCase { _ = try session.saveChannel(payload: .dummy(cid: cid), query: nil, cache: nil) _ = try session.saveMessage(payload: .dummy(messageId: messageId, authorUserId: "test-user"), for: cid, cache: nil) - let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? ReminderDueEvent) + let domainEvent = try XCTUnwrap(event?.toDomainEvent(session: session) as? MessageReminderDueEvent) XCTAssertEqual(domainEvent.messageId, messageId) XCTAssertEqual(domainEvent.reminder.id, messageId) XCTAssertEqual(domainEvent.reminder.channel.cid, cid) From 6d5a78dfd8f51e4eb109b02b3a8e8e7120806dee Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Apr 2025 12:59:22 +0100 Subject: [PATCH 41/42] Use new "converting" helper function to simply reminders repository --- .../Repositories/RemindersRepository.swift | 64 +++++++------------ 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/Sources/StreamChat/Repositories/RemindersRepository.swift b/Sources/StreamChat/Repositories/RemindersRepository.swift index e38bf753576..302796a73cd 100644 --- a/Sources/StreamChat/Repositories/RemindersRepository.swift +++ b/Sources/StreamChat/Repositories/RemindersRepository.swift @@ -38,19 +38,16 @@ class RemindersRepository { apiClient.request(endpoint: .queryReminders(query: query)) { [weak self] result in switch result { case .success(let response): - var reminders: [MessageReminder] = [] - self?.database.write({ session in - reminders = try response.reminders.compactMap { payload in - let reminderDTO = try session.saveReminder(payload: payload, cache: nil) - return try reminderDTO.asModel() - } - }, completion: { error in - if let error { - completion(.failure(error)) - return - } - completion(.success(ReminderListResponse(reminders: reminders, next: response.next))) - }) + self?.database.write( + converting: { session in + let reminders = try response.reminders.compactMap { payload in + let reminderDTO = try session.saveReminder(payload: payload, cache: nil) + return try reminderDTO.asModel() + } + return ReminderListResponse(reminders: reminders, next: response.next) + }, + completion: completion + ) case .failure(let error): completion(.failure(error)) } @@ -94,23 +91,15 @@ class RemindersRepository { } } completion: { _ in // Make the API call to create the reminder - self.apiClient.request(endpoint: endpoint) { result in + self.apiClient.request(endpoint: endpoint) { [weak self] result in switch result { case .success(let payload): - var reminder: MessageReminder! - self.database.write({ session in - let messageReminder = payload.reminder - reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() - }, completion: { error in - if let error { - completion(.failure(error)) - } else { - completion(.success(reminder)) - } - }) + self?.database.write(converting: { + try $0.saveReminder(payload: payload.reminder, cache: nil).asModel() + }, completion: completion) case .failure(let error): // Rollback the optimistic update if the API call fails - self.database.write({ session in + self?.database.write({ session in session.deleteReminder(messageId: messageId) }, completion: { _ in completion(.failure(error)) @@ -149,23 +138,14 @@ class RemindersRepository { originalRemindAt = messageDTO.reminder?.remindAt?.bridgeDate messageDTO.reminder?.remindAt = remindAt?.bridgeDate - } completion: { [weak self] _ in + } completion: { _ in // Make the API call to update the reminder - self?.apiClient.request(endpoint: endpoint) { result in + self.apiClient.request(endpoint: endpoint) { [weak self] result in switch result { case .success(let payload): - var reminder: MessageReminder! - self?.database.write({ session in - let messageReminder = payload.reminder - reminder = try session.saveReminder(payload: messageReminder, cache: nil).asModel() - }, completion: { error in - if let error { - completion(.failure(error)) - } else { - completion(.success(reminder)) - } - }) - + self?.database.write(converting: { + try $0.saveReminder(payload: payload.reminder, cache: nil).asModel() + }, completion: completion) case .failure(let error): self?.database.write({ session in // Restore original value @@ -219,9 +199,9 @@ class RemindersRepository { // Delete optimistically session.deleteReminder(messageId: messageId) - } completion: { [weak self] _ in + } completion: { _ in // Make the API call to delete the reminder - self?.apiClient.request(endpoint: endpoint) { result in + self.apiClient.request(endpoint: endpoint) { [weak self] result in switch result { case .success: completion(nil) From 59f7fca5e6b03f964e2f8837eb38c5eaeae22d90 Mon Sep 17 00:00:00 2001 From: Nuno Vieira Date: Wed, 2 Apr 2025 13:05:02 +0100 Subject: [PATCH 42/42] Remove some unnecessary warnings --- .../Repositories/RemindersRepository.swift | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Sources/StreamChat/Repositories/RemindersRepository.swift b/Sources/StreamChat/Repositories/RemindersRepository.swift index 302796a73cd..5ad8cf67c90 100644 --- a/Sources/StreamChat/Repositories/RemindersRepository.swift +++ b/Sources/StreamChat/Repositories/RemindersRepository.swift @@ -83,12 +83,7 @@ class RemindersRepository { createdAt: now, updatedAt: now ) - - do { - try session.saveReminder(payload: reminderPayload, cache: nil) - } catch { - log.warning("Failed to optimistically create reminder in the database: \(error)") - } + try session.saveReminder(payload: reminderPayload, cache: nil) } completion: { _ in // Make the API call to create the reminder self.apiClient.request(endpoint: endpoint) { [weak self] result in @@ -215,11 +210,7 @@ class RemindersRepository { self?.database.write({ session in // Restore original reminder - do { - try session.saveReminder(payload: originalPayload, cache: nil) - } catch { - log.warning("Failed to rollback reminder deletion: \(error)") - } + try session.saveReminder(payload: originalPayload, cache: nil) }, completion: { _ in completion(error) })