Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
47c083b
Add scheduled messages to the controllers
nuno-vieira Oct 23, 2026
a3b4495
Add the scheduled messages changelog entry
nuno-vieira Oct 23, 2026
370c841
Remove isPending and isOnServer from ScheduledMessage
nuno-vieira Oct 24, 2026
8337951
Use the generated scheduled message status and failure reason
nuno-vieira Oct 26, 2026
66f4648
Delete a scheduled message on the server when it is deleted while sch…
nuno-vieira Oct 26, 2026
8370c2b
Resend the scheduled message changes stuck in syncing
nuno-vieira Oct 26, 2026
1d78256
Rename the scheduled messages page to ScheduledMessageListResponse
nuno-vieira Oct 26, 2026
9052492
Keep the failed local changes of a scheduled message
nuno-vieira Oct 26, 2026
9f36b83
Delete the missing scheduled messages through the database session
nuno-vieira Oct 26, 2026
b338c8b
Log the scheduled message extra data that can not be encoded
nuno-vieira Oct 26, 2026
14c41ee
Rename apply(_:to:) to saveContent(_:to:)
nuno-vieira Oct 26, 2026
beaa69d
Call the next page of the scheduled messages query a cursor
nuno-vieira Oct 26, 2026
cf160d6
Rename ScheduledMessageError to ScheduledMessageFailure
nuno-vieira Oct 26, 2026
07ab256
Link the failure of a scheduled message in the failed status docs
nuno-vieira Oct 26, 2026
970761b
Pass extraData last in the scheduling methods
nuno-vieira Oct 26, 2026
9537b22
Send the scheduled messages instead of list changes to the delegate
nuno-vieira Oct 26, 2026
bf6d81e
Move the scheduling methods into the channel and message controllers
nuno-vieira Oct 26, 2026
5d84bd0
Handle the scheduled message events with their WSEvent
nuno-vieira Oct 26, 2026
f0999c1
Stop linking scheduled messages when their channel is created
nuno-vieira Oct 26, 2026
f4277a5
Use the generated ScheduledMessageFailureReason for the publishing fa…
nuno-vieira Oct 26, 2026
366b4fb
Rename the publishing status of a scheduled message to sending
nuno-vieira Oct 26, 2026
51a416c
Validate scheduled messages locally and clarify their failures
nuno-vieira Oct 27, 2026
adbd307
Send a scheduled message now only after its changes reach the server
nuno-vieira Oct 27, 2026
e69245b
Make ScheduledMessage Sendable without unchecked
nuno-vieira Oct 27, 2026
686d07e
Wait with expectations instead of AssertAsync in the scheduled messag…
nuno-vieira Oct 27, 2026
ea68083
Shorten the scheduled messages changelog entry
nuno-vieira Oct 27, 2026
d487375
Fix the CI for the scheduled messages tests
nuno-vieira Oct 27, 2026
f25e4c4
Test that the scheduled message list updates when the content changes
nuno-vieira Oct 28, 2026
88a1133
Stop treating a scheduled message being sent as retryable
nuno-vieira Oct 28, 2026
e72166c
Expose the scheduled message limits on the channel config
nuno-vieira Oct 28, 2026
c4fd8c7
Check the scheduled message limits locally before saving
nuno-vieira Oct 28, 2026
125e9f7
Add scheduled messages to the State Layer
nuno-vieira Oct 23, 2026
6399131
Load the scheduled messages of the State Layer with ScheduledMessageL…
nuno-vieira Oct 26, 2026
037a676
Stop loading more scheduled messages once all are loaded
nuno-vieira Oct 26, 2026
e903579
Use the sending status in the State Layer tests
nuno-vieira Oct 26, 2026
8de24e8
Clarify that State Layer scheduling does not wait for the server
nuno-vieira Oct 27, 2026
5d81ef8
Send a scheduled message now from the State Layer once its changes ar…
nuno-vieira Oct 27, 2026
a3ed12d
Make extraData the last argument of the State Layer scheduling methods
nuno-vieira Oct 28, 2026
400a2f7
Test that the State Layer scheduled message list updates when the con…
nuno-vieira Oct 28, 2026
05be6e3
Add scheduled messages to the demo app
nuno-vieira Oct 26, 2026
b0efba9
Update the demo app to the scheduled messages API changes
nuno-vieira Oct 26, 2026
887cfa6
Use ScheduledMessageFailureReason in the demo app
nuno-vieira Oct 26, 2026
4975e0a
Use the sending status in the demo app
nuno-vieira Oct 26, 2026
58d5a5b
Confirm scheduled messages once the server answers in the demo app
nuno-vieira Oct 27, 2026
38ad0cc
Confirm scheduled messages once they pass the local checks in the dem…
nuno-vieira Oct 27, 2026
916db34
Keep the last message clear of the scheduled messages banner in the d…
nuno-vieira Oct 27, 2026
7c8fa93
Scroll to the last message when the scheduled messages banner appears…
nuno-vieira Oct 27, 2026
423ddef
Remove the unused scheduled date separator from the demo app
nuno-vieira Oct 28, 2026
8f62398
Stop offering to retry a scheduled message that is being sent in the …
nuno-vieira Oct 28, 2026
0948d96
Pick custom scheduled dates within the channel config limits in the d…
nuno-vieira Oct 28, 2026
ca638e1
Merge branch 'develop' into add/scheduled-messages-demo-app
nuno-vieira Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ struct DemoAppConfig {
var shouldShowPremiumBadge: Bool
/// A Boolean value to define if the reminders feature is enabled.
var isRemindersEnabled: Bool
/// A Boolean value to define if the scheduled messages feature is enabled.
var isScheduledMessagesEnabled: Bool
/// A Boolean value to define if the poll should be deleted when the message is deleted.
var shouldDeletePollOnMessageDeletion: Bool

Expand All @@ -49,6 +51,14 @@ class AppConfig: @unchecked Sendable {
/// The Demo App Configuration.
var demoAppConfig: DemoAppConfig

/// Whether the scheduled messages features are shown.
///
/// The backend does not tell clients whether the app has scheduled messages enabled, and still returns the
/// `schedule-message` capability when it is disabled, so only the US East staging app, where it is enabled, shows them.
var isScheduledMessagesEnabled: Bool {
demoAppConfig.isScheduledMessagesEnabled && apiKeyString == DemoApiKeys.usEastStaging.rawValue
}

static let shared = AppConfig()

private init() {
Expand All @@ -63,6 +73,7 @@ class AppConfig: @unchecked Sendable {
isPremiumMemberFeatureEnabled: false,
shouldShowPremiumBadge: false,
isRemindersEnabled: true,
isScheduledMessagesEnabled: true,
shouldDeletePollOnMessageDeletion: false
)

Expand All @@ -74,6 +85,7 @@ class AppConfig: @unchecked Sendable {
demoAppConfig.isPremiumMemberFeatureEnabled = true
demoAppConfig.shouldShowPremiumBadge = true
demoAppConfig.isRemindersEnabled = true
demoAppConfig.isScheduledMessagesEnabled = true
demoAppConfig.shouldDeletePollOnMessageDeletion = true
demoAppConfig.isMessageDeliveredInfoEnabled = true
StreamRuntimeCheck.assertionsEnabled = true
Expand Down Expand Up @@ -191,6 +203,7 @@ class AppConfigViewController: UITableViewController {
case isPremiumMemberFeatureEnabled
case shouldShowPremiumBadge
case isRemindersEnabled
case isScheduledMessagesEnabled
case logSettings

var title: String {
Expand Down Expand Up @@ -377,6 +390,10 @@ class AppConfigViewController: UITableViewController {
cell.accessoryView = makeSwitchButton(demoAppConfig.isRemindersEnabled) { [weak self] newValue in
self?.demoAppConfig.isRemindersEnabled = newValue
}
case .isScheduledMessagesEnabled:
cell.accessoryView = makeSwitchButton(demoAppConfig.isScheduledMessagesEnabled) { [weak self] newValue in
self?.demoAppConfig.isScheduledMessagesEnabled = newValue
}
case .logSettings:
cell.accessoryType = .disclosureIndicator
}
Expand Down Expand Up @@ -756,7 +773,7 @@ class AppConfigViewController: UITableViewController {

private func pushEnvironmentSelectorVC() {
let selectorViewController = OptionsSelectorViewController<DemoApiKeys>(
options: [.frankfurtC1, .frankfurtC2, .usEastC6],
options: [.frankfurtC1, .frankfurtC2, .usEastC6, .usEastStaging],
initialSelectedOptions: [DemoApiKeys(rawValue: apiKeyString)],
allowsMultipleSelection: false,
optionFormatter: { option in
Expand Down
44 changes: 39 additions & 5 deletions DemoApp/Screens/DemoAppTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import StreamChatCommonUI
import StreamChatUI
import UIKit

class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, MessageReminderListControllerDelegate {
class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, MessageReminderListControllerDelegate, ScheduledMessageListControllerDelegate {
private var locationProvider = LocationProvider.shared

let channelListVC: UIViewController
let threadListVC: UIViewController
let draftListVC: UIViewController
let reminderListVC: UIViewController
let scheduledMessageListVC: UIViewController
let currentUserController: CurrentChatUserController
let allRemindersListController: MessageReminderListController
let allScheduledMessagesListController: ScheduledMessageListController

// Events controller for listening to chat events
private var eventsController: EventsController!
Expand All @@ -26,15 +28,19 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele
threadListVC: UIViewController,
draftListVC: UIViewController,
reminderListVC: UIViewController,
scheduledMessageListVC: UIViewController,
currentUserController: CurrentChatUserController,
allRemindersListController: MessageReminderListController
allRemindersListController: MessageReminderListController,
allScheduledMessagesListController: ScheduledMessageListController
) {
self.channelListVC = channelListVC
self.threadListVC = threadListVC
self.draftListVC = draftListVC
self.reminderListVC = reminderListVC
self.scheduledMessageListVC = scheduledMessageListVC
self.currentUserController = currentUserController
self.allRemindersListController = allRemindersListController
self.allScheduledMessagesListController = allScheduledMessagesListController
super.init(nibName: nil, bundle: nil)
}

Expand Down Expand Up @@ -97,12 +103,28 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele
reminderListVC.tabBarItem.title = "Reminders"
reminderListVC.tabBarItem.image = UIImage(systemName: "bell")

scheduledMessageListVC.tabBarItem.title = "Scheduled"
scheduledMessageListVC.tabBarItem.image = UIImage(systemName: "clock")

var tabs = [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]
tabs.append(reminderListVC)
}
if AppConfig.shared.isScheduledMessagesEnabled {
tabs.append(scheduledMessageListVC)
allScheduledMessagesListController.delegate = self
allScheduledMessagesListController.synchronize()
updateScheduledMessagesBadge()
}
viewControllers = tabs
}

func showScheduledMessages() {
guard viewControllers?.contains(scheduledMessageListVC) == true else { return }

(scheduledMessageListVC as? UINavigationController)?.popToRootViewController(animated: false)
selectedViewController = scheduledMessageListVC
}

func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) {
Expand Down Expand Up @@ -154,4 +176,16 @@ class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDele
let reminders = allRemindersListController.reminders
reminderListVC.tabBarItem.badgeValue = reminders.isEmpty ? nil : "\(reminders.count)"
}

func controller(
_ controller: ScheduledMessageListController,
didChangeScheduledMessages scheduledMessages: [ScheduledMessage]
) {
updateScheduledMessagesBadge()
}

private func updateScheduledMessagesBadge() {
let pendingCount = allScheduledMessagesListController.scheduledMessages.filter { $0.status != .failed }.count
scheduledMessageListVC.tabBarItem.badgeValue = pendingCount > 0 ? "\(pendingCount)" : nil
}
}
14 changes: 12 additions & 2 deletions DemoApp/Screens/LoginViewController/LoginViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,20 @@ class LoginViewController: UIViewController, UIAdaptivePresentationControllerDel
@IBOutlet var tableView: UITableView!
var onUserSelection: ((DemoUserType) -> Void)!

var users: [DemoUserType] {
/// The users shown in the table view. They depend on the selected environment, so they are
/// only refreshed together with a table view reload, to keep the data source consistent.
private(set) var users: [DemoUserType] = LoginViewController.makeUsers()

private static func makeUsers() -> [DemoUserType] {
UserCredentials.builtInUsers.map { DemoUserType.credentials($0) }
+ [.guest("guest"), .anonymous, .custom(nil)]
}

private func reloadUsers() {
users = LoginViewController.makeUsers()
tableView.reloadData()
}

override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
Expand All @@ -32,6 +41,7 @@ class LoginViewController: UIViewController, UIAdaptivePresentationControllerDel
super.viewWillAppear(animated)

navigationController?.isNavigationBarHidden = true
reloadUsers()
if let selectedRow = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: selectedRow, animated: true)
}
Expand All @@ -45,7 +55,7 @@ class LoginViewController: UIViewController, UIAdaptivePresentationControllerDel
}

func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
tableView.reloadData()
reloadUsers()
}
}

Expand Down
87 changes: 87 additions & 0 deletions DemoApp/Screens/ScheduledMessages/DemoComposerBannerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Copyright Β© 2026 Stream.io Inc. All rights reserved.
//

import StreamChatUI
import UIKit

final class DemoComposerBannerView: UIView {
var onTap: (() -> Void)? {
didSet { updateAccessibility() }
}

var onDismiss: (() -> Void)? {
didSet { dismissButton.isHidden = onDismiss == nil }
}

var title: String? {
get { titleLabel.text }
set {
titleLabel.text = newValue
updateAccessibility()
}
}

private let iconView = UIImageView(image: UIImage(systemName: "clock"))
private let titleLabel = UILabel()
private let dismissButton = UIButton(type: .system)

override init(frame: CGRect) {
super.init(frame: frame)

let colors = Appearance.default.colorPalette
backgroundColor = colors.backgroundCoreSurfaceStrong
layer.cornerRadius = 12

iconView.tintColor = colors.accentPrimary
iconView.contentMode = .scaleAspectFit
iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .footnote)
iconView.setContentHuggingPriority(.required, for: .horizontal)

titleLabel.font = .preferredFont(forTextStyle: .footnote)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textColor = colors.textPrimary
titleLabel.numberOfLines = 0

dismissButton.setImage(UIImage(systemName: "xmark"), for: .normal)
dismissButton.tintColor = colors.textSecondary
dismissButton.accessibilityLabel = "Cancel"
dismissButton.isHidden = true
dismissButton.setContentHuggingPriority(.required, for: .horizontal)
dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)

let stackView = UIStackView(arrangedSubviews: [iconView, titleLabel, dismissButton])
stackView.spacing = 8
stackView.alignment = .center
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12)
])

addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

@objc private func tapped() {
onTap?()
}

@objc private func dismissTapped() {
onDismiss?()
}

private func updateAccessibility() {
// A tappable banner reads as a single button; otherwise the dismiss button stays reachable.
isAccessibilityElement = onTap != nil
accessibilityLabel = titleLabel.text
accessibilityTraits = onTap != nil ? .button : .none
}
}
Loading
Loading