Skip to content

Commit 5b89e49

Browse files
nuno-vieiraStream-SDK-BotStream Bottestableapple
authored
Add support for message delivered info (#3846)
* Add Channel Delivered endpoint and payloads * Add `CurrentChatUserController.markChannelsDelivered()` * Handle marking channels as delivered in the ChannelListController * Add `lastDeliveredAt` and `lastDeliveredMessageId` properties * Add `ChannelDeliveredMiddleware` * Message delivered event handling * Add example to the demo app to view the delivery info * Fix handling delivered event tests * Mark message as delivered in the demo app when a push is received Expose markMessageAsDelivered in chat notification handler * Fix AppConfigViewController incorrect delivery flag name * Simplify notification service * Improve the naming of getting the message of a channel to be marked as delivered * Rename markChannelsDelivered -> markMessagesAsDelivered * Improve the channel delivered middleware safeness * Add a `ChannelDeliveryTracker` to handle only the throttling and submitting channels for delivery * Use the tracker in the notification service * Make sure to update the latest message whenever it is delivered to a user * Add documentation to latestMessageNotMarkedAsDelivered() * Update CHANGELOG.md * Fix forgotten super call in demo app code * Change the type of `latestMessageNotMarkedAsDelivered` to `ChatMessage` * Add `ChatChannel.reads(message:)` and `ChatChannel.deliveredReads(message:)` * Update CHANGELOG.md * Make the `DemoMessageReadsInfoView` live updated * Remove delivery tracker from Push Notification Service Since the NSE is spawned fresh everytime it can't have in-memory state * Change Message Events to classes to improve SDK size * Add sorting tie breaker to the reads info view * Cancel message delivery if it was delivered from another device of the current user * Add `ChatChannelConfig.deliveredEventsEnabled` * Centralize logic to check if a message can be marked as delivered * Fix tests with new canMarkMessageAsDelivered logic * Add user delivery receipts settings * Use the privacy settings to check canMarkasDelivered * Fix UI tests not compiling * Update CHANGELOG.md * Fix wrong config name * Make the notifcation handler safer * Remove `CurrentUserController.markMessageAsDelivered()` since it should not be exposed. * Remove `ChatChannel.latestUndeliveredMessage` * Add `MessageDeliveryCriteriaValidator` * Fix default privacy setting value * Self review changes * Comparison dates robostuness * Remove outdated documentation * Fix deliveredReads sometimes not returning when message was indeed delivered * Make SwiftUI ENV the default of the Demo App * Add `MessageDeliveryStatus.delivered` + Double grey checkmark when message is delivered * Add test coverage to the new delivered status * Update CHANGELOG.md * Fix ChannelConfig mock in tests * AI Code review changes * Fix channel list controller tests * Update CHANGELOG.md * [CI] Snapshots (#3864) Co-authored-by: Stream Bot <[email protected]> * Move delivered reload cell to the SDK * Only refresh last cell if needed * Fix e2e tests api key issue (#3865) * Revert channel config mock tests for E2E Tests * Revert "Make SwiftUI ENV the default of the Demo App" This reverts commit df89bc7. --------- Co-authored-by: Stream SDK Bot <[email protected]> Co-authored-by: Stream Bot <[email protected]> Co-authored-by: Alexey Alter-Pesotskiy <[email protected]>
1 parent d8f0e2f commit 5b89e49

File tree

95 files changed

+3344
-145
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

95 files changed

+3344
-145
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44
# Upcoming
55

66
## StreamChat
7+
### ✅ Added
8+
- Add support for message delivered info [#3846](https://github.com/GetStream/stream-chat-swift/pull/3846)
9+
- Add `ChatRemoteNotificationHandler.markMessageAsDelivered(deliveries:)`
10+
- Add `ChatChannel.reads(message:)` and `ChatChannel.deliveredReads(message:)`
11+
- Add `ChatChannelRead.lastDeliveredAt`
12+
- Add `ChatChannelRead.lastDeliveredMessageId`
13+
- Add `MessageDeliveryStatus.delivered`
714
### 🐞 Fixed
815
- Fix `ChannelController.hasLoadedAllPreviousMessages` not correct for newly created channels [#3855](https://github.com/GetStream/stream-chat-swift/pull/3855)
916
- 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)
1017
- 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)
1118
- Fix syncing error when trying to sync too many channels [#3863](https://github.com/GetStream/stream-chat-swift/pull/3863)
19+
### 🔄 Changed
20+
- Deprecated `ChatMessage.deliveryStatus` in favour of `ChatMessage.deliveryStatus(channel:)` [#3846](https://github.com/GetStream/stream-chat-swift/pull/3846)
1221

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

DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ struct DemoAppConfig {
1414
var isAtlantisEnabled: Bool
1515
/// A Boolean value to define if an additional message debugger action will be added.
1616
var isMessageDebuggerEnabled: Bool
17+
/// A boolean value to define if message delivered info should be enabled.
18+
/// If enabled it will include a message action to see message reads and delivery info.
19+
/// It will also display double grey check-marks for delivered messages in the message list.
20+
var isMessageDeliveredInfoEnabled: Bool
1721
/// Set this value to define if we should mimic token refresh scenarios.
1822
var tokenRefreshDetails: TokenRefreshDetails?
1923
/// A Boolean value that determines if a connection banner UI should be shown.
@@ -49,6 +53,7 @@ class AppConfig {
4953
isHardDeleteEnabled: false,
5054
isAtlantisEnabled: false,
5155
isMessageDebuggerEnabled: false,
56+
isMessageDeliveredInfoEnabled: false,
5257
tokenRefreshDetails: nil,
5358
shouldShowConnectionBanner: false,
5459
isPremiumMemberFeatureEnabled: false,
@@ -64,6 +69,7 @@ class AppConfig {
6469
demoAppConfig.isPremiumMemberFeatureEnabled = true
6570
demoAppConfig.isRemindersEnabled = true
6671
demoAppConfig.shouldDeletePollOnMessageDeletion = true
72+
demoAppConfig.isMessageDeliveredInfoEnabled = true
6773
StreamRuntimeCheck.assertionsEnabled = true
6874
}
6975
}
@@ -74,6 +80,7 @@ class UserConfig {
7480
var language: TranslationLanguage?
7581
var typingIndicatorsEnabled: Bool?
7682
var readReceiptsEnabled: Bool?
83+
var deliveryReceiptsEnabled: Bool?
7784

7885
static var shared = UserConfig()
7986

@@ -172,6 +179,7 @@ class AppConfigViewController: UITableViewController {
172179
case isHardDeleteEnabled
173180
case isAtlantisEnabled
174181
case isMessageDebuggerEnabled
182+
case isMessageDeliveredInfoEnabled
175183
case tokenRefreshDetails
176184
case shouldShowConnectionBanner
177185
case isPremiumMemberFeatureEnabled
@@ -323,6 +331,10 @@ class AppConfigViewController: UITableViewController {
323331
cell.accessoryView = makeSwitchButton(demoAppConfig.isMessageDebuggerEnabled) { [weak self] newValue in
324332
self?.demoAppConfig.isMessageDebuggerEnabled = newValue
325333
}
334+
case .isMessageDeliveredInfoEnabled:
335+
cell.accessoryView = makeSwitchButton(demoAppConfig.isMessageDeliveredInfoEnabled) { [weak self] newValue in
336+
self?.demoAppConfig.isMessageDeliveredInfoEnabled = newValue
337+
}
326338
case .tokenRefreshDetails:
327339
if let tokenRefreshDuration = demoAppConfig.tokenRefreshDetails?.expirationDuration {
328340
cell.detailTextLabel?.text = "Duration before expired: \(tokenRefreshDuration)s"

DemoApp/Screens/UserProfile/UserProfileViewController.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
1818
case role
1919
case typingIndicatorsEnabled
2020
case readReceiptsEnabled
21+
case deliveryReceiptsEnabled
2122
case pushPreferences
2223
case detailedUnreadCounts
2324
case avgResponseTime
@@ -58,7 +59,6 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
5859
updateButton.backgroundColor = .systemBlue
5960
updateButton.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 15, bottom: 0.0, right: 15)
6061
updateButton.addTarget(self, action: #selector(didTapUpdateButton), for: .touchUpInside)
61-
updateButton.isHidden = !StreamRuntimeCheck.isStreamInternalConfiguration
6262

6363
NSLayoutConstraint.activate([
6464
imageView.widthAnchor.constraint(equalToConstant: 60),
@@ -109,6 +109,11 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
109109
cell.accessoryView = makeSwitchButton(UserConfig.shared.typingIndicatorsEnabled ?? true) { newValue in
110110
UserConfig.shared.typingIndicatorsEnabled = newValue
111111
}
112+
case .deliveryReceiptsEnabled:
113+
cell.textLabel?.text = "Delivery Receipts Enabled"
114+
cell.accessoryView = makeSwitchButton(UserConfig.shared.deliveryReceiptsEnabled ?? true) { newValue in
115+
UserConfig.shared.deliveryReceiptsEnabled = newValue
116+
}
112117
case .pushPreferences:
113118
cell.textLabel?.text = "Push Preferences"
114119
cell.detailTextLabel?.text = "Configure notification settings"
@@ -149,6 +154,9 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
149154
if let readReceiptsEnabled = currentUserController.currentUser?.privacySettings.readReceipts?.enabled {
150155
UserConfig.shared.readReceiptsEnabled = readReceiptsEnabled
151156
}
157+
if let deliveryReceiptsEnabled = currentUserController.currentUser?.privacySettings.deliveryReceipts?.enabled {
158+
UserConfig.shared.deliveryReceiptsEnabled = deliveryReceiptsEnabled
159+
}
152160

153161
tableView.reloadData()
154162
}
@@ -199,7 +207,8 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
199207
name: name,
200208
privacySettings: .init(
201209
typingIndicators: UserConfig.shared.typingIndicatorsEnabled.map { .init(enabled: $0) },
202-
readReceipts: UserConfig.shared.readReceiptsEnabled.map { .init(enabled: $0) }
210+
readReceipts: UserConfig.shared.readReceiptsEnabled.map { .init(enabled: $0) },
211+
deliveryReceipts: UserConfig.shared.deliveryReceiptsEnabled.map { .init(enabled: $0) }
203212
)
204213
)
205214
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Combine
6+
import StreamChat
7+
import StreamChatUI
8+
import SwiftUI
9+
10+
struct DemoMessageReadsInfoView: View {
11+
let message: ChatMessage
12+
let channelController: ChatChannelController
13+
14+
@Environment(\.dismiss) private var dismiss
15+
@State private var channel: ChatChannel?
16+
@State private var cancellables = Set<AnyCancellable>()
17+
18+
init(message: ChatMessage, channelController: ChatChannelController) {
19+
self.message = message
20+
self.channelController = channelController
21+
self._channel = State(initialValue: channelController.channel)
22+
}
23+
24+
var body: some View {
25+
NavigationView {
26+
List {
27+
if !deliveredUsers.isEmpty {
28+
Section("Delivered") {
29+
ForEach(deliveredUsers, id: \.id) { user in
30+
UserReadInfoRow(
31+
user: user,
32+
status: .delivered,
33+
timestamp: getDeliveredTimestamp(for: user)
34+
)
35+
}
36+
}
37+
}
38+
39+
if !readUsers.isEmpty {
40+
Section("Read") {
41+
ForEach(readUsers, id: \.id) { user in
42+
UserReadInfoRow(
43+
user: user,
44+
status: .read,
45+
timestamp: getReadTimestamp(for: user)
46+
)
47+
}
48+
}
49+
}
50+
51+
if deliveredUsers.isEmpty && readUsers.isEmpty {
52+
Section {
53+
HStack {
54+
Spacer()
55+
VStack(spacing: 8) {
56+
Image(systemName: "eye.slash")
57+
.font(.title2)
58+
.foregroundColor(.secondary)
59+
Text("No reads yet")
60+
.font(.subheadline)
61+
.foregroundColor(.secondary)
62+
}
63+
Spacer()
64+
}
65+
.padding(.vertical, 20)
66+
}
67+
}
68+
}
69+
.navigationTitle("Message Info")
70+
.navigationBarTitleDisplayMode(.inline)
71+
.toolbar {
72+
ToolbarItem(placement: .navigationBarTrailing) {
73+
Button("Done") {
74+
dismiss()
75+
}
76+
}
77+
}
78+
.onAppear {
79+
setupChannelObserver()
80+
}
81+
}
82+
}
83+
84+
// MARK: - Computed Properties
85+
86+
private var deliveredUsers: [ChatUser] {
87+
channel?.deliveredReads(for: message)
88+
.sorted {
89+
$0.lastDeliveredAt ?? Date.distantPast < $1.lastDeliveredAt ?? Date.distantPast
90+
&& $0.user.id < $1.user.id
91+
}
92+
.map(\.user) ?? []
93+
}
94+
95+
private var readUsers: [ChatUser] {
96+
channel?.reads(for: message)
97+
.sorted {
98+
$0.lastReadAt < $1.lastReadAt
99+
&& $0.user.id < $1.user.id
100+
}
101+
.map(\.user) ?? []
102+
}
103+
104+
// MARK: - Helper Methods
105+
106+
private func setupChannelObserver() {
107+
channelController.channelChangePublisher
108+
.receive(on: DispatchQueue.main)
109+
.sink { channelChange in
110+
switch channelChange {
111+
case .create(let newChannel), .update(let newChannel):
112+
self.channel = newChannel
113+
case .remove:
114+
break
115+
}
116+
}
117+
.store(in: &cancellables)
118+
}
119+
120+
private func getDeliveredTimestamp(for user: ChatUser) -> Date? {
121+
channel?.reads
122+
.first { $0.user.id == user.id }
123+
.flatMap { $0.lastDeliveredAt }
124+
}
125+
126+
private func getReadTimestamp(for user: ChatUser) -> Date? {
127+
channel?.reads
128+
.first { $0.user.id == user.id }
129+
.flatMap { $0.lastReadAt }
130+
}
131+
}
132+
133+
// MARK: - User Read Info Row
134+
135+
struct UserReadInfoRow: View {
136+
let user: ChatUser
137+
let status: ReadStatus
138+
let timestamp: Date?
139+
140+
enum ReadStatus {
141+
case delivered
142+
case read
143+
144+
var icon: String {
145+
switch self {
146+
case .delivered:
147+
return "checkmark"
148+
case .read:
149+
return "checkmark"
150+
}
151+
}
152+
153+
var color: Color {
154+
switch self {
155+
case .delivered:
156+
return .gray
157+
case .read:
158+
return .blue
159+
}
160+
}
161+
}
162+
163+
var body: some View {
164+
HStack(spacing: 12) {
165+
// User Avatar
166+
AsyncImage(url: user.imageURL) { image in
167+
image
168+
.resizable()
169+
.aspectRatio(contentMode: .fill)
170+
} placeholder: {
171+
Circle()
172+
.fill(Color.gray.opacity(0.3))
173+
.overlay(
174+
Text(user.name?.prefix(1).uppercased() ?? user.id)
175+
.font(.headline)
176+
.foregroundColor(.primary)
177+
)
178+
}
179+
.frame(width: 40, height: 40)
180+
.clipShape(Circle())
181+
182+
// User Info
183+
VStack(alignment: .leading, spacing: 2) {
184+
Text(user.name ?? user.id)
185+
.font(.headline)
186+
.foregroundColor(.primary)
187+
188+
if let timestamp = timestamp {
189+
Text(formatTimestamp(timestamp))
190+
.font(.caption)
191+
.foregroundColor(.secondary)
192+
}
193+
}
194+
195+
Spacer()
196+
197+
// Status Icon
198+
Image(systemName: status.icon)
199+
.foregroundColor(status.color)
200+
.font(.title3)
201+
}
202+
.padding(.vertical, 4)
203+
}
204+
205+
private func formatTimestamp(_ date: Date) -> String {
206+
let formatter = DateFormatter()
207+
formatter.dateStyle = .none
208+
formatter.timeStyle = .short
209+
210+
let calendar = Calendar.current
211+
if calendar.isDateInToday(date) {
212+
return "Today at \(formatter.string(from: date))"
213+
} else if calendar.isDateInYesterday(date) {
214+
return "Yesterday at \(formatter.string(from: date))"
215+
} else {
216+
formatter.dateStyle = .short
217+
return formatter.string(from: date)
218+
}
219+
}
220+
}

0 commit comments

Comments
 (0)