Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### πŸ”„ Changed

# [4.92.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.92.0)
_November 05, 2025_

## StreamChat
### βœ… Added
- Add support for message delivered info [#3846](https://github.com/GetStream/stream-chat-swift/pull/3846)
- Add `ChatRemoteNotificationHandler.markMessageAsDelivered(message:channel:)`
- Add `ChatChannel.reads(message:)` and `ChatChannel.deliveredReads(message:)`
- Add `ChatChannelRead.lastDeliveredAt`
- Add `ChatChannelRead.lastDeliveredMessageId`
- Add `MessageDeliveryStatus.delivered`
### 🐞 Fixed
- Fix `ChannelController.hasLoadedAllPreviousMessages` not correct for newly created channels [#3855](https://github.com/GetStream/stream-chat-swift/pull/3855)
- Fix duplicated watch channel requests when a channel is created and it belongs to multiple queries [#3857](https://github.com/GetStream/stream-chat-swift/pull/3857)
- Fix calling watch channel request when a channel is updated and it already belongs to another query [#3857](https://github.com/GetStream/stream-chat-swift/pull/3857)
- Fix syncing error when trying to sync too many channels [#3863](https://github.com/GetStream/stream-chat-swift/pull/3863)
### πŸ”„ Changed
- Deprecated `ChatMessage.deliveryStatus` in favour of `ChatMessage.deliveryStatus(channel:)` [#3846](https://github.com/GetStream/stream-chat-swift/pull/3846)

## StreamChatUI
### βœ… Added
- Display double grey checkmark when delivery events are enabled [#3846](https://github.com/GetStream/stream-chat-swift/pull/3846)
### 🐞 Fixed
- Fix date separator not shown on newly created channel [#3855](https://github.com/GetStream/stream-chat-swift/pull/3855)
- Fix composer deleting newly entered text after deleting draft text [#3854](https://github.com/GetStream/stream-chat-swift/pull/3854)

# [4.91.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.91.0)
_October 22, 2025_

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ struct DemoAppConfig {
var isAtlantisEnabled: Bool
/// A Boolean value to define if an additional message debugger action will be added.
var isMessageDebuggerEnabled: Bool
/// A boolean value to define if message delivered info should be enabled.
/// If enabled it will include a message action to see message reads and delivery info.
/// It will also display double grey check-marks for delivered messages in the message list.
var isMessageDeliveredInfoEnabled: Bool
/// Set this value to define if we should mimic token refresh scenarios.
var tokenRefreshDetails: TokenRefreshDetails?
/// A Boolean value that determines if a connection banner UI should be shown.
Expand Down Expand Up @@ -49,6 +53,7 @@ class AppConfig {
isHardDeleteEnabled: false,
isAtlantisEnabled: false,
isMessageDebuggerEnabled: false,
isMessageDeliveredInfoEnabled: false,
tokenRefreshDetails: nil,
shouldShowConnectionBanner: false,
isPremiumMemberFeatureEnabled: false,
Expand All @@ -64,6 +69,7 @@ class AppConfig {
demoAppConfig.isPremiumMemberFeatureEnabled = true
demoAppConfig.isRemindersEnabled = true
demoAppConfig.shouldDeletePollOnMessageDeletion = true
demoAppConfig.isMessageDeliveredInfoEnabled = true
StreamRuntimeCheck.assertionsEnabled = true
}
}
Expand All @@ -74,6 +80,7 @@ class UserConfig {
var language: TranslationLanguage?
var typingIndicatorsEnabled: Bool?
var readReceiptsEnabled: Bool?
var deliveryReceiptsEnabled: Bool?

static var shared = UserConfig()

Expand Down Expand Up @@ -172,6 +179,7 @@ class AppConfigViewController: UITableViewController {
case isHardDeleteEnabled
case isAtlantisEnabled
case isMessageDebuggerEnabled
case isMessageDeliveredInfoEnabled
case tokenRefreshDetails
case shouldShowConnectionBanner
case isPremiumMemberFeatureEnabled
Expand Down Expand Up @@ -323,6 +331,10 @@ class AppConfigViewController: UITableViewController {
cell.accessoryView = makeSwitchButton(demoAppConfig.isMessageDebuggerEnabled) { [weak self] newValue in
self?.demoAppConfig.isMessageDebuggerEnabled = newValue
}
case .isMessageDeliveredInfoEnabled:
cell.accessoryView = makeSwitchButton(demoAppConfig.isMessageDeliveredInfoEnabled) { [weak self] newValue in
self?.demoAppConfig.isMessageDeliveredInfoEnabled = newValue
}
case .tokenRefreshDetails:
if let tokenRefreshDuration = demoAppConfig.tokenRefreshDetails?.expirationDuration {
cell.detailTextLabel?.text = "Duration before expired: \(tokenRefreshDuration)s"
Expand Down
13 changes: 11 additions & 2 deletions DemoApp/Screens/UserProfile/UserProfileViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
case role
case typingIndicatorsEnabled
case readReceiptsEnabled
case deliveryReceiptsEnabled
case pushPreferences
case detailedUnreadCounts
case avgResponseTime
Expand Down Expand Up @@ -58,7 +59,6 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
updateButton.backgroundColor = .systemBlue
updateButton.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 15, bottom: 0.0, right: 15)
updateButton.addTarget(self, action: #selector(didTapUpdateButton), for: .touchUpInside)
updateButton.isHidden = !StreamRuntimeCheck.isStreamInternalConfiguration

NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 60),
Expand Down Expand Up @@ -109,6 +109,11 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
cell.accessoryView = makeSwitchButton(UserConfig.shared.typingIndicatorsEnabled ?? true) { newValue in
UserConfig.shared.typingIndicatorsEnabled = newValue
}
case .deliveryReceiptsEnabled:
cell.textLabel?.text = "Delivery Receipts Enabled"
cell.accessoryView = makeSwitchButton(UserConfig.shared.deliveryReceiptsEnabled ?? true) { newValue in
UserConfig.shared.deliveryReceiptsEnabled = newValue
}
case .pushPreferences:
cell.textLabel?.text = "Push Preferences"
cell.detailTextLabel?.text = "Configure notification settings"
Expand Down Expand Up @@ -149,6 +154,9 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
if let readReceiptsEnabled = currentUserController.currentUser?.privacySettings.readReceipts?.enabled {
UserConfig.shared.readReceiptsEnabled = readReceiptsEnabled
}
if let deliveryReceiptsEnabled = currentUserController.currentUser?.privacySettings.deliveryReceipts?.enabled {
UserConfig.shared.deliveryReceiptsEnabled = deliveryReceiptsEnabled
}

tableView.reloadData()
}
Expand Down Expand Up @@ -199,7 +207,8 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
name: name,
privacySettings: .init(
typingIndicators: UserConfig.shared.typingIndicatorsEnabled.map { .init(enabled: $0) },
readReceipts: UserConfig.shared.readReceiptsEnabled.map { .init(enabled: $0) }
readReceipts: UserConfig.shared.readReceiptsEnabled.map { .init(enabled: $0) },
deliveryReceipts: UserConfig.shared.deliveryReceiptsEnabled.map { .init(enabled: $0) }
)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//
// Copyright Β© 2025 Stream.io Inc. All rights reserved.
//

import Combine
import StreamChat
import StreamChatUI
import SwiftUI

struct DemoMessageReadsInfoView: View {
let message: ChatMessage
let channelController: ChatChannelController

@Environment(\.dismiss) private var dismiss
@State private var channel: ChatChannel?
@State private var cancellables = Set<AnyCancellable>()

init(message: ChatMessage, channelController: ChatChannelController) {
self.message = message
self.channelController = channelController
self._channel = State(initialValue: channelController.channel)
}

var body: some View {
NavigationView {
List {
if !deliveredUsers.isEmpty {
Section("Delivered") {
ForEach(deliveredUsers, id: \.id) { user in
UserReadInfoRow(
user: user,
status: .delivered,
timestamp: getDeliveredTimestamp(for: user)
)
}
}
}

if !readUsers.isEmpty {
Section("Read") {
ForEach(readUsers, id: \.id) { user in
UserReadInfoRow(
user: user,
status: .read,
timestamp: getReadTimestamp(for: user)
)
}
}
}

if deliveredUsers.isEmpty && readUsers.isEmpty {
Section {
HStack {
Spacer()
VStack(spacing: 8) {
Image(systemName: "eye.slash")
.font(.title2)
.foregroundColor(.secondary)
Text("No reads yet")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 20)
}
}
}
.navigationTitle("Message Info")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.onAppear {
setupChannelObserver()
}
}
}

// MARK: - Computed Properties

private var deliveredUsers: [ChatUser] {
channel?.deliveredReads(for: message)
.sorted {
$0.lastDeliveredAt ?? Date.distantPast < $1.lastDeliveredAt ?? Date.distantPast
&& $0.user.id < $1.user.id
}
.map(\.user) ?? []
}

private var readUsers: [ChatUser] {
channel?.reads(for: message)
.sorted {
$0.lastReadAt < $1.lastReadAt
&& $0.user.id < $1.user.id
}
.map(\.user) ?? []
}

// MARK: - Helper Methods

private func setupChannelObserver() {
channelController.channelChangePublisher
.receive(on: DispatchQueue.main)
.sink { channelChange in
switch channelChange {
case .create(let newChannel), .update(let newChannel):
self.channel = newChannel
case .remove:
break
}
}
.store(in: &cancellables)
}

private func getDeliveredTimestamp(for user: ChatUser) -> Date? {
channel?.reads
.first { $0.user.id == user.id }
.flatMap { $0.lastDeliveredAt }
}

private func getReadTimestamp(for user: ChatUser) -> Date? {
channel?.reads
.first { $0.user.id == user.id }
.flatMap { $0.lastReadAt }
}
}

// MARK: - User Read Info Row

struct UserReadInfoRow: View {
let user: ChatUser
let status: ReadStatus
let timestamp: Date?

enum ReadStatus {
case delivered
case read

var icon: String {
switch self {
case .delivered:
return "checkmark"
case .read:
return "checkmark"
}
}

var color: Color {
switch self {
case .delivered:
return .gray
case .read:
return .blue
}
}
}

var body: some View {
HStack(spacing: 12) {
// User Avatar
AsyncImage(url: user.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(user.name?.prefix(1).uppercased() ?? user.id)
.font(.headline)
.foregroundColor(.primary)
)
}
.frame(width: 40, height: 40)
.clipShape(Circle())

// User Info
VStack(alignment: .leading, spacing: 2) {
Text(user.name ?? user.id)
.font(.headline)
.foregroundColor(.primary)

if let timestamp = timestamp {
Text(formatTimestamp(timestamp))
.font(.caption)
.foregroundColor(.secondary)
}
}

Spacer()

// Status Icon
Image(systemName: status.icon)
.foregroundColor(status.color)
.font(.title3)
}
.padding(.vertical, 4)
}

private func formatTimestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short

let calendar = Calendar.current
if calendar.isDateInToday(date) {
return "Today at \(formatter.string(from: date))"
} else if calendar.isDateInYesterday(date) {
return "Yesterday at \(formatter.string(from: date))"
} else {
formatter.dateStyle = .short
return formatter.string(from: date)
}
}
}
Loading
Loading