diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9c2c1ea2e..b5fa599d6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +## 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` # [4.76.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.76.0) _March 31, 2025_ diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 5bb0d19a69f..16dce2e862a 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: true ) 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 + } } } @@ -735,6 +744,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/Screens/DemoAppTabBarController.swift b/DemoApp/Screens/DemoAppTabBarController.swift index 082f36de7f4..ec776b4baa5 100644 --- a/DemoApp/Screens/DemoAppTabBarController.swift +++ b/DemoApp/Screens/DemoAppTabBarController.swift @@ -6,22 +6,31 @@ import StreamChat import StreamChatUI import UIKit -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! init( channelListVC: UIViewController, threadListVC: UIViewController, draftListVC: UIViewController, - currentUserController: CurrentChatUserController + reminderListVC: UIViewController, + 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) } @@ -52,6 +61,12 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele currentUserController.delegate = self unreadCount = currentUserController.unreadCount + // Update reminders badge if the feature is enabled. + if AppConfig.shared.demoAppConfig.isRemindersEnabled { + allRemindersListController.delegate = self + updateRemindersBadge() + } + tabBar.backgroundColor = Appearance.default.colorPalette.background tabBar.isTranslucent = true @@ -65,14 +80,34 @@ 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] + // Only show reminders tab if the feature is enabled + if AppConfig.shared.demoAppConfig.isRemindersEnabled { + viewControllers = [channelListVC, threadListVC, draftListVC, reminderListVC] + } else { + viewControllers = [channelListVC, threadListVC, draftListVC] + } } - + func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { let unreadCount = didChangeCurrentUserUnreadCount self.unreadCount = unreadCount let totalUnreadBadge = unreadCount.channels + unreadCount.threads UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge } + + func controller( + _ controller: MessageReminderListController, + didChangeReminders changes: [ListChange] + ) { + updateRemindersBadge() + } + + private func updateRemindersBadge() { + let reminders = allRemindersListController.reminders + reminderListVC.tabBarItem.badgeValue = reminders.isEmpty ? nil : "\(reminders.count)" + } } diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift new file mode 100644 index 00000000000..2c28e9ba30a --- /dev/null +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -0,0 +1,764 @@ +// +// 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 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) + + private lazy var eventsController = currentUserController.client.eventsController() + + // 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" + } + } + + 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 { + switchToController(for: selectedFilter) + 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 + activeController = currentUserController.client.messageReminderListController() + + 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" + + eventsController.delegate = self + + 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 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 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 + } + 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 + updateRemindersData() + } + } + + private func loadReminders() { + let controller = activeController + controller.delegate = self + + if reminders.isEmpty { + loadingIndicator.startAnimating() + emptyStateView.isHidden = true + } + + controller.synchronize { [weak self] _ in + self?.loadingIndicator.stopAnimating() + } + } + + private func loadMoreReminders() { + let controller = activeController + guard !isPaginatingReminders && !controller.hasLoadedAllReminders else { + return + } + + isPaginatingReminders = true + controller.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) + } + + private func updateRemindersData() { + reminders = Array(activeController.reminders) + tableView.reloadData() + updateEmptyStateMessage() + emptyStateView.isHidden = !reminders.isEmpty + } +} + +// MARK: - 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 } + updateRemindersData() + } + + func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + if event is MessageReminderDueEvent { + 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 + } + activeController.delegate = self + updateRemindersData() + } +} + +// 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)" + } + + 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 { + // 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/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() diff --git a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift index 6336fa12d6d..e60ffde0278 100644 --- a/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatMessageActionsVC.swift @@ -20,6 +20,11 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC { if message?.isBounced == false { actions.append(pinMessageActionItem()) actions.append(translateActionItem()) + + if AppConfig.shared.demoAppConfig.isRemindersEnabled { + actions.append(reminderActionItem()) + actions.append(saveForLaterActionItem()) + } } if AppConfig.shared.demoAppConfig.isMessageDebuggerEnabled { @@ -83,6 +88,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 +211,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() + } + } + } } 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..7c0be2d7f8e 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 AppConfig.shared.demoAppConfig.isRemindersEnabled && message.reminder != nil { + options.insert(.saveForLaterInfo) + } + return options } } diff --git a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift index e63244d3ff0..604b0ab4278 100644 --- a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift +++ b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift @@ -57,12 +57,24 @@ 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), - currentUserController: client.currentUserController() + reminderListVC: UINavigationController(rootViewController: reminderListVC), + currentUserController: client.currentUserController(), + allRemindersListController: client.messageReminderListController() ) set(rootViewController: tabBarViewController, animated: animated) DemoAppConfiguration.showPerformanceTracker() 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/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift index 880be5f98ac..4cd1add2fe6 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 } } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift new file mode 100644 index 00000000000..b05af6b755b --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ReminderPayloads.swift @@ -0,0 +1,79 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// An object describing a reminder JSON payload. +class 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 +class 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 +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 + } +} 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/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 3fa6d723ce6..bc27e8b5974 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 remindersRepositoryBuilder: ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> RemindersRepository = { + RemindersRepository(database: $0, apiClient: $1) + } + var channelListUpdaterBuilder: ( _ database: DatabaseContainer, _ apiClient: APIClient diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 598967f5e19..6c269a8e2c1 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 remindersRepository: RemindersRepository = { + environment.remindersRepositoryBuilder(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/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/Controllers/CurrentUserController/CurrentUserController.swift b/Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift index 6ef29d6bd3f..8397e77e252 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,8 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt return Array(observer.items) } + // MARK: - Init + /// Creates a new `CurrentUserControllerGeneric`. /// /// - Parameters: @@ -120,9 +124,10 @@ public class CurrentChatUserController: DataController, DelegateCallable, DataSt self.client = client self.environment = environment draftMessagesRepository = client.draftMessagesRepository + super.init() } - /// 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 diff --git a/Sources/StreamChat/Controllers/MessageController/MessageController.swift b/Sources/StreamChat/Controllers/MessageController/MessageController.swift index de88b4c0f67..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() @@ -930,6 +934,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? = nil, + completion: ((Result) -> Void)? = nil + ) { + remindersRepository.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 + ) { + remindersRepository.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 + ) { + remindersRepository.deleteReminder( + messageId: messageId, + cid: cid + ) { error in + self.callback { + completion?(error) + } + } + } } // MARK: - Environment diff --git a/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift new file mode 100644 index 00000000000..17c26ca55c0 --- /dev/null +++ b/Sources/StreamChat/Controllers/MessageReminderListController/MessageReminderListController+Combine.swift @@ -0,0 +1,47 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +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) + } + } +} + +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/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 83c8ff2bfe5..3ba00a62c73 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`. @@ -1040,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. @@ -1786,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/Database/DTOs/MessageReminderDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift new file mode 100644 index 00000000000..09a72316d18 --- /dev/null +++ b/Sources/StreamChat/Database/DTOs/MessageReminderDTO.swift @@ -0,0 +1,172 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation + +@objc(MessageReminderDTO) +class MessageReminderDTO: NSManagedObject { + @NSManaged var id: String + @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) + request.predicate = NSPredicate(format: "message.id == %@", messageId) + request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageReminderDTO.createdAt, ascending: false)] + return request + } + + /// Returns a fetch request for message reminders based on the provided query. + static func remindersFetchRequest(query: MessageReminderListQuery) -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageReminderDTO.entityName) + 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 + } + + /// 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.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.id = payload.messageId + 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) { + let message = message(id: messageId) + guard let reminderDTO = message?.reminder else { + return + } + delete(reminderDTO) + message?.reminder = nil + } +} + +// 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..28ed68db324 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,18 @@ + + + + + + + + + + + + diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index f75be99d7f1..3ff4f61a822 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 ) } } @@ -438,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/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 new file mode 100644 index 00000000000..6e76d5e87cf --- /dev/null +++ b/Sources/StreamChat/Models/MessageReminder.swift @@ -0,0 +1,75 @@ +// +// 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) + } +} + +/// 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: 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? + + /// 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/Query/Filter+ChatChannel.swift b/Sources/StreamChat/Query/Filter+predicate.swift similarity index 92% rename from Sources/StreamChat/Query/Filter+ChatChannel.swift rename to Sources/StreamChat/Query/Filter+predicate.swift index 63b258b5421..62c4e8b560c 100644 --- a/Sources/StreamChat/Query/Filter+ChatChannel.swift +++ b/Sources/StreamChat/Query/Filter+predicate.swift @@ -4,43 +4,12 @@ import Foundation -extension Filter where Scope == ChannelListFilterScope { - /// 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 +extension Filter { + /// 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 where Scope == ChannelListFilterScope { 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 { 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( diff --git a/Sources/StreamChat/Query/MessageReminderListQuery.swift b/Sources/StreamChat/Query/MessageReminderListQuery.swift new file mode 100644 index 00000000000..5dfa8f6922a --- /dev/null +++ b/Sources/StreamChat/Query/MessageReminderListQuery.swift @@ -0,0 +1,126 @@ +// +// 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 cid: FilterKey { .init( + rawValue: "channel_cid", + keyPathString: #keyPath(MessageReminderDTO.channel.cid), + valueMapper: { $0.rawValue } + ) } + + /// 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)) } + + /// 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: #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. +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 + } + + /// 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 + + /// 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. + public init( + filter: Filter? = nil, + sort: [Sorting] = [.init(key: .remindAt, isAscending: true)], + pageSize: Int = 25, + next: String? = nil + ) { + self.filter = filter + self.sort = sort + pagination = Pagination(pageSize: pageSize, cursor: next) + } + + 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 pagination.encode(to: encoder) + } +} + +extension MessageReminderListQuery: CustomDebugStringConvertible { + public var debugDescription: String { + "Filter: \(String(describing: filter)) | Sort: \(sort)" + } +} diff --git a/Sources/StreamChat/Repositories/RemindersRepository.swift b/Sources/StreamChat/Repositories/RemindersRepository.swift new file mode 100644 index 00000000000..5ad8cf67c90 --- /dev/null +++ b/Sources/StreamChat/Repositories/RemindersRepository.swift @@ -0,0 +1,221 @@ +// +// 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): + 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)) + } + } + } + + /// 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 + ) + 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 + switch result { + case .success(let payload): + 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 + 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: { _ in + // Make the API call to update the reminder + self.apiClient.request(endpoint: endpoint) { [weak self] result in + switch result { + case .success(let payload): + 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 + 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: { _ in + // Make the API call to delete the reminder + self.apiClient.request(endpoint: endpoint) { [weak self] 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 + try session.saveReminder(payload: originalPayload, cache: nil) + }, completion: { _ in + completion(error) + }) + } + } + } + } +} 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..504ef2e2502 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 messageReminderCreated: Self = "reminder.created" + + /// When a reminder was updated. + static let messageReminderUpdated: Self = "reminder.updated" + + /// When a reminder was deleted. + static let messageReminderDeleted: Self = "reminder.deleted" + + /// When a reminder is due. + static let messageReminderDue: 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 .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/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 ) } } diff --git a/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ReminderEvents.swift new file mode 100644 index 00000000000..865d6f0e12a --- /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 MessageReminderCreatedEvent: 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 MessageReminderCreatedEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} + +/// Triggered when a message reminder is updated. +public class MessageReminderUpdatedEvent: 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 MessageReminderUpdatedEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} + +/// Triggered when a message reminder is deleted. +public class MessageReminderDeletedEvent: 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 MessageReminderDeletedEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} + +/// Triggered when a reminder is due and a notification should be shown. +public class MessageReminderDueEvent: 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 MessageReminderDueEvent( + messageId: messageId, + reminder: reminderModel, + createdAt: createdAt + ) + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b2d9a14d7c5..ee0353001b8 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1672,6 +1672,19 @@ 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 */; }; + 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 */; }; @@ -1681,9 +1694,33 @@ 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 */; }; + 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 */; }; + ADB8B8F02D8A493900549C95 /* ReminderPayload.json in Resources */ = {isa = PBXBuildFile; fileRef = ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */; }; + 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 */; }; + 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 */; }; @@ -2454,8 +2491,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 */; }; @@ -4386,6 +4423,15 @@ 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 = ""; }; + 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 = ""; }; @@ -4394,9 +4440,27 @@ 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 = ""; }; + 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 /* 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 = ""; }; + 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 = ""; }; @@ -4635,7 +4699,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 = ""; }; @@ -5536,6 +5600,7 @@ 79280F402484F4DD00CDEB89 /* Events */ = { isa = PBXGroup; children = ( + ADB8B9092D8C756600549C95 /* ReminderEvents.swift */, 79280F46248515FA00CDEB89 /* ChannelEvents.swift */, 79280F4A248523C000CDEB89 /* ConnectionEvents.swift */, 79280F412484F4EC00CDEB89 /* Event.swift */, @@ -5579,6 +5644,7 @@ 792A4F18247EA97000EAF71D /* DTOs */ = { isa = PBXGroup; children = ( + ADB8B8E92D8890B900549C95 /* MessageReminderDTO.swift */, DABC6AC7254707CB00A8FC78 /* AttachmentDTO.swift */, AD52A2182804850700D0157E /* ChannelConfigDTO.swift */, 799C942A247D2FB9001F1104 /* ChannelDTO.swift */, @@ -5660,7 +5726,7 @@ isa = PBXGroup; children = ( 792A4F432480107A00EAF71D /* Filter.swift */, - C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */, + C1FFD9F827ECC7C7008A6848 /* Filter+predicate.swift */, 792A4F4C248011E500EAF71D /* ChannelListQuery.swift */, 882C5745252C6FDF00E60C44 /* ChannelMemberListQuery.swift */, 792A4F422480107A00EAF71D /* ChannelQuery.swift */, @@ -5668,6 +5734,7 @@ AD6E32A02BBC50110073831B /* ThreadListQuery.swift */, AD6E32A32BBC502D0073831B /* ThreadQuery.swift */, AD545E622D528271008FD399 /* DraftListQuery.swift */, + ADB2087E2D849184003F1059 /* MessageReminderListQuery.swift */, AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */, 7978FBB926E15A58002CA2DF /* MessageSearchQuery.swift */, 792A4F442480107A00EAF71D /* Pagination.swift */, @@ -5741,6 +5808,7 @@ 796610B7248E64EC00761629 /* EventMiddlewares */ = { isa = PBXGroup; children = ( + ADB8B90E2D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift */, AD545E732D5A79B5008FD399 /* DraftUpdaterMiddleware.swift */, 79896D63250A62EE00BA8F1C /* ChannelReadUpdaterMiddleware.swift */, AD9632E02C0A43630073B814 /* ThreadUpdaterMiddleware.swift */, @@ -5762,6 +5830,7 @@ 79682C4724BF37550071578E /* Payloads */ = { isa = PBXGroup; children = ( + ADA83B4F2D978050003B3928 /* ReminderPayloads.swift */, ADF2BBEA2B9B622B0069D467 /* AppSettingsPayload.swift */, DA9985ED24E175AA000E9885 /* ChannelCodingKeys.swift */, DAFAD6A224DD8E1A0043ED06 /* ChannelEditDetailPayload.swift */, @@ -5823,6 +5892,7 @@ 79877A122498E4EE00015F8B /* Endpoints */ = { isa = PBXGroup; children = ( + ADA83B4C2D977D64003B3928 /* ReminderEndpoints.swift */, 88E26D7C2580F95300F55AB5 /* AttachmentEndpoints.swift */, 82C18FDB2C10C8E600C5283C /* BlockedUserPayload.swift */, 79877A132498E4EE00015F8B /* ChannelEndpoints.swift */, @@ -5931,6 +6001,7 @@ 79877A022498E4BB00015F8B /* Device.swift */, 79877A032498E4BB00015F8B /* Member.swift */, AD70DC3B2ADEF09C00CFC3B7 /* MessageModerationDetails.swift */, + ADB8B8EC2D8890E000549C95 /* MessageReminder.swift */, AD7AC98B260A94C6004AADA5 /* MessagePinning.swift */, 8899BC52254318CC003CB98B /* MessageReaction.swift */, AD8258A22BD2939500B9ED74 /* MessageReactionGroup.swift */, @@ -5978,6 +6049,7 @@ DAE566F22500F97E00E39431 /* ChannelController */, DAE566F32500F98D00E39431 /* ChannelListController */, 79C5CBF925F671AE00D98001 /* ChannelWatcherListController */, + ADA83B352D9742CD003B3928 /* MessageReminderListController */, AD9490552BF3BA8000E69224 /* ThreadListController */, ADF34F6925CD6A0100AD637C /* ConnectionController */, DAE566F42500F99900E39431 /* CurrentUserController */, @@ -6598,6 +6670,7 @@ 8A62705F24BE31B20040BFD6 /* Events */ = { isa = PBXGroup; children = ( + ADB8B8FE2D8C6FED00549C95 /* Reminder */, AD545E8A2D5D8095008FD399 /* Draft */, 84E46A332CFA1B73000CBDDE /* AIIndicator */, ADE57B802C3C5C4600DD6B88 /* Thread */, @@ -6781,6 +6854,7 @@ A3227ECA284A607D00EBE6CC /* Screens */ = { isa = PBXGroup; children = ( + ADB8B8F42D8ADC9400549C95 /* DemoReminderListVC.swift */, AD545E682D5531B9008FD399 /* DemoDraftMessageListVC.swift */, ADD328592C04DD8300BAD0E9 /* DemoAppTabBarController.swift */, C10B5C712A1F794A006A5BCB /* MembersViewController.swift */, @@ -6818,6 +6892,7 @@ A344074F27D753530044F150 /* Models + Extensions */ = { isa = PBXGroup; children = ( + ADA83B482D976EC7003B3928 /* MessageReminder_Mock.swift */, ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */, A344075027D753530044F150 /* ChannelUnreadCount_Mock.swift */, A344075127D753530044F150 /* ChatChannel_Mock.swift */, @@ -6991,6 +7066,7 @@ A364D08D27D0BD8E0029857A /* EventMiddlewares */ = { isa = PBXGroup; children = ( + ADB8B9112D8C7B2D00549C95 /* ReminderUpdaterMiddleware_Tests.swift */, AD545E8D2D5D827B008FD399 /* DraftUpdaterMiddleware_Tests.swift */, 79896D65250A6D1500BA8F1C /* ChannelReadUpdaterMiddleware_Tests.swift */, AD7BE16C2C20CC02000A5756 /* ThreadUpdaterMiddlware_Tests.swift */, @@ -7012,6 +7088,7 @@ A364D08E27D0BDB20029857A /* Events */ = { isa = PBXGroup; children = ( + ADB8B90C2D8C784500549C95 /* ReminderEvents_Tests.swift */, AD545E862D5D805A008FD399 /* DraftEvents_Tests.swift */, 8A62706B24BF3DBC0040BFD6 /* ChannelEvents_Tests.swift */, 84A1D2EF26AB10DB00014712 /* EventDecoder_Tests.swift */, @@ -7065,6 +7142,7 @@ A364D09327D0BF330029857A /* Endpoints */ = { isa = PBXGroup; children = ( + ADA83B4A2D977D59003B3928 /* ReminderEndpoints_Tests.swift */, AD545E762D5BB3D6008FD399 /* DraftEndpoints_Tests.swift */, AD8C7C652BA46A4A00260715 /* AppEndpoints_Tests.swift */, 88381E8625825A240047A6A3 /* AttachmentEndpoints_Tests.swift */, @@ -7092,6 +7170,7 @@ A364D09427D0BF3A0029857A /* Payloads */ = { isa = PBXGroup; children = ( + ADA83B522D97805A003B3928 /* ReminderPayloads_Tests.swift */, AD545E6A2D5650B5008FD399 /* DraftPayloads_Tests.swift */, AD8C7C622BA464E600260715 /* AppSettingsPayload_Tests.swift */, AD6E32952BBB10890073831B /* ThreadListPayload_Tests.swift */, @@ -7312,6 +7391,7 @@ A364D0A327D126490029857A /* Repositories */ = { isa = PBXGroup; children = ( + ADB8B8F62D8B846D00549C95 /* RemindersRepository_Tests.swift */, AD545E782D5BC14E008FD399 /* DraftMessagesRepository_Tests.swift */, C11BAA4C2907EC7B004C5EA4 /* AuthenticationRepository_Tests.swift */, C18514FC292E34E10033387E /* ConnectionRepository_Tests.swift */, @@ -7328,6 +7408,7 @@ A364D0A527D127E00029857A /* Controllers */ = { isa = PBXGroup; children = ( + ADA83B442D97511E003B3928 /* MessageReminderListController */, AD94905E2BF65CC500E69224 /* ThreadListController */, A364D0A827D128650029857A /* ChannelController */, A364D0A927D128830029857A /* ChannelListController */, @@ -7460,6 +7541,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 */, @@ -7509,6 +7591,7 @@ A364D0B727D12A520029857A /* Query */ = { isa = PBXGroup; children = ( + ADB208812D8494F0003F1059 /* MessageReminderListQuery_Tests.swift */, AD545E842D5D7591008FD399 /* DraftListQuery_Tests.swift */, A3C7BAD027E4E02700BBF4FA /* ChannelListFilterScope_Tests.swift */, 799F611A2530B62C007F218C /* ChannelListQuery_Tests.swift */, @@ -8136,6 +8219,7 @@ A3C729552840BA4800FFE8B4 /* JSONs */ = { isa = PBXGroup; children = ( + ADB8B8EF2D8A493900549C95 /* ReminderPayload.json */, AD545E6C2D565316008FD399 /* DraftMessage.json */, 798779F72498E47700015F8B /* Channel.json */, AD6E32972BBB13650073831B /* Thread.json */, @@ -8771,6 +8855,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 = ( @@ -8797,6 +8899,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 = ( @@ -9132,6 +9245,7 @@ C12D0A5E28FD58CE0099895A /* Repositories */ = { isa = PBXGroup; children = ( + ADB8B8F82D8B8A0C00549C95 /* RemindersRepository_Mock.swift */, AD545E7E2D5CFC36008FD399 /* DraftMessagesRepository_Mock.swift */, C12D0A5F28FD59B60099895A /* AuthenticationRepository_Mock.swift */, A344074E27D753530044F150 /* ConnectionRepository_Mock.swift */, @@ -9266,6 +9380,7 @@ C1E8AD59278DDC500041B775 /* Repositories */ = { isa = PBXGroup; children = ( + ADB8B8F12D8ADA0700549C95 /* RemindersRepository.swift */, C135A1CA28F45F6B0058EFB6 /* AuthenticationRepository.swift */, 88206FC325B18C88009D086A /* ConnectionRepository.swift */, C1B0B38527BFE8AB00C8207D /* MessageRepository.swift */, @@ -10232,6 +10347,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 */, @@ -10261,6 +10377,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 */, @@ -10276,11 +10393,13 @@ 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 */, 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 */, @@ -10301,6 +10420,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 */, @@ -11100,6 +11220,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 */, @@ -11212,6 +11333,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 */, @@ -11257,6 +11379,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 */, @@ -11324,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 */, @@ -11378,6 +11502,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 */, @@ -11462,6 +11588,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 */, @@ -11477,6 +11604,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 */, @@ -11539,12 +11667,13 @@ 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 */, 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 */, @@ -11594,6 +11723,7 @@ C1E8AD5E278EF5F30041B775 /* AsyncOperation.swift in Sources */, 88D85DA7252F3C1D00AE1030 /* MemberListController.swift in Sources */, 79280F4B248523C000CDEB89 /* ConnectionEvents.swift in Sources */, + ADB8B8F32D8ADA0700549C95 /* RemindersRepository.swift in Sources */, 79158CF425F133FB00186102 /* ChannelTruncatedEventMiddleware.swift in Sources */, 882C574A252C767E00E60C44 /* ChannelMemberListPayload.swift in Sources */, DAFAD6A324DD8E1A0043ED06 /* ChannelEditDetailPayload.swift in Sources */, @@ -11629,6 +11759,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 */, @@ -11641,6 +11772,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 */, @@ -11652,6 +11784,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 */, @@ -11771,6 +11904,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 */, @@ -11825,6 +11959,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 */, @@ -11852,6 +11987,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 */, @@ -11873,6 +12009,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 */, @@ -11892,6 +12029,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 */, @@ -12014,12 +12152,14 @@ 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 */, 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 */, @@ -12040,6 +12180,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 */, @@ -12063,6 +12204,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 */, ); @@ -12295,6 +12437,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 */, @@ -12304,6 +12447,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 */, @@ -12374,6 +12519,7 @@ C121E852274544AE00023E4C /* ModerationEndpoints.swift in Sources */, C121E853274544AE00023E4C /* WebSocketConnectEndpoint.swift in Sources */, 4F73F39F2B91C7BF00563CD9 /* MessageState+Observer.swift in Sources */, + ADB8B8F22D8ADA0700549C95 /* RemindersRepository.swift in Sources */, C121E854274544AE00023E4C /* MemberEndpoints.swift in Sources */, C121E855274544AE00023E4C /* AttachmentEndpoints.swift in Sources */, C121E856274544AE00023E4C /* ChatRemoteNotificationHandler.swift in Sources */, @@ -12393,6 +12539,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 */, @@ -12530,6 +12677,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 */, @@ -12563,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 */, @@ -12575,6 +12724,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 */, @@ -12596,9 +12746,11 @@ 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 */, + ADB8B9102D8C7B2500549C95 /* ReminderUpdaterMiddleware.swift in Sources */, AD37D7C82BC98A4400800D8C /* ThreadParticipantDTO.swift in Sources */, C121E8D0274544B100023E4C /* UserListSortingKey.swift in Sources */, C121E8D1274544B100023E4C /* ChannelMemberListSortingKey.swift in Sources */, @@ -12660,7 +12812,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 */, 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/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/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/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/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/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/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/Repositories/RemindersRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift new file mode 100644 index 00000000000..b0eedd0a9fe --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/RemindersRepository_Mock.swift @@ -0,0 +1,123 @@ +// +// 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_callCount: Int = 0 + 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_callCount += 1 + 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/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")) } } 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()) + } +} 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) + } +} 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/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 + ) + } +} 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)), diff --git a/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift new file mode 100644 index 00000000000..1985c25d30e --- /dev/null +++ b/Tests/StreamChatTests/Query/MessageReminderListQuery_Tests.swift @@ -0,0 +1,112 @@ +// +// 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) + } + + func test_customInitialization() { + let filter = Filter.equal(.cid, to: ChannelId.unique) + let sort = [Sorting(key: .createdAt, isAscending: false)] + + let query = MessageReminderListQuery( + filter: filter, + sort: sort, + pageSize: 10 + ) + + 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) + } + + func test_encode_withAllFields() throws { + 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( + filter: filter, + sort: sort, + pageSize: 10 + ) + + let expectedData: [String: Any] = [ + "filter": ["channel_cid": ["$eq": cid.rawValue]], + "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_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 cid: ChannelId = .init(type: .messaging, id: "123") + let filter = Filter.equal(.cid, to: cid) + + let query = MessageReminderListQuery( + filter: filter, + sort: [], + pageSize: 10 + ) + + let expectedData: [String: Any] = [ + "filter": ["channel_cid": ["$eq": cid.rawValue]], + "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.cid.rawValue, "channel_cid") + XCTAssertEqual(FilterKey.remindAt.rawValue, "remind_at") + XCTAssertEqual(FilterKey.createdAt.rawValue, "created_at") + } + + 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") + } +} 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/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift b/Tests/StreamChatTests/WebSocketClient/EventMiddlewares/ReminderUpdaterMiddleware_Tests.swift new file mode 100644 index 00000000000..52042d05fda --- /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: .messageReminderCreated, + 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: .messageReminderUpdated, + 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") + XCTAssertNearlySameDate(reminder?.remindAt?.bridgeDate, updatedDate) + } + + 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: .messageReminderDue, + 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: .messageReminderDeleted, + 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..9534d3f1f48 --- /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? MessageReminderCreatedEvent) + 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? MessageReminderUpdatedEvent) + 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? MessageReminderDeletedEvent) + 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? MessageReminderDueEvent) + 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)) + } +}