Skip to content

Commit 955f978

Browse files
authored
Mark messages as unread using timestamp (#3885)
1 parent fa38d7a commit 955f978

File tree

27 files changed

+565
-76
lines changed

27 files changed

+565
-76
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
99
- Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
1010
- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
1111
- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
12+
- Add `ChatChannelController.markUnread(from:completion:)` and `Chat.markUnread(from:)` where the from argument is `Date` [#3885](https://github.com/GetStream/stream-chat-swift/pull/3885)
1213
- Add support for filter tags in channels [#3886](https://github.com/GetStream/stream-chat-swift/pull/3886)
1314
- Add `ChatChannel.filterTags`
1415
- Add `filterTags` channel list filtering key

DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,24 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
463463
}
464464
}
465465
}),
466+
.init(title: "Mark channel unread with timestamp", isEnabled: true, handler: { [unowned self] _ in
467+
self.rootViewController.presentAlert(title: "Mark messages as unread with timestamp", message: "Marks messages as unread from the last number of days", textFieldPlaceholder: "Days") { offsetInDaysString in
468+
let calendar = Calendar.current
469+
guard let offsetInDays = Int(offsetInDaysString ?? ""),
470+
let date = calendar.date(byAdding: .day, value: -abs(offsetInDays), to: calendar.startOfDay(for: Date())) else {
471+
self.rootViewController.presentAlert(title: "Timestamp offset is not valid")
472+
return
473+
}
474+
channelController.markUnread(from: date) { result in
475+
switch result {
476+
case .failure(let error):
477+
self.rootViewController.presentAlert(title: "Couldn't mark messages as unread \(cid)", message: "\(error)")
478+
case .success:
479+
break
480+
}
481+
}
482+
}
483+
}),
466484
.init(title: "Cool channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
467485
channelController.partialChannelUpdate(extraData: ["is_cool": true]) { error in
468486
if let error = error {

Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,16 +269,13 @@ extension Endpoint {
269269
)
270270
}
271271

272-
static func markUnread(cid: ChannelId, messageId: MessageId, userId: UserId) -> Endpoint<EmptyResponse> {
272+
static func markUnread(cid: ChannelId, payload: MarkUnreadPayload) -> Endpoint<EmptyResponse> {
273273
.init(
274274
path: .markChannelUnread(cid.apiPath),
275275
method: .post,
276276
queryItems: nil,
277277
requiresConnectionId: false,
278-
body: [
279-
"message_id": messageId,
280-
"user_id": userId
281-
]
278+
body: payload
282279
)
283280
}
284281

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
enum MarkUnreadCriteria: Sendable, Equatable {
8+
/// The ID of the message from where the channel is marked unread
9+
case messageId(String)
10+
/// The timestamp of the message from where the channel is marked unread
11+
case messageTimestamp(Date)
12+
}
13+
14+
struct MarkUnreadPayload: Encodable, Sendable {
15+
let criteria: MarkUnreadCriteria
16+
let userId: String
17+
18+
func encode(to encoder: any Encoder) throws {
19+
var container = encoder.container(keyedBy: CodingKeys.self)
20+
try container.encode(userId, forKey: .userId)
21+
switch criteria {
22+
case .messageId(let messageId):
23+
try container.encode(messageId, forKey: .messageId)
24+
case .messageTimestamp(let messageTimestamp):
25+
try container.encode(messageTimestamp, forKey: .messageTimestamp)
26+
}
27+
}
28+
29+
private enum CodingKeys: String, CodingKey {
30+
case messageId = "message_id"
31+
case messageTimestamp = "message_timestamp"
32+
case userId = "user_id"
33+
}
34+
}

Sources/StreamChat/Controllers/ChannelController/ChannelController.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1333,7 +1333,47 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
13331333
}
13341334

13351335
readStateHandler.markUnread(
1336-
from: messageId,
1336+
from: .messageId(messageId),
1337+
in: channel
1338+
) { [weak self] result in
1339+
self?.callback {
1340+
completion?(result)
1341+
}
1342+
}
1343+
}
1344+
1345+
/// Marks all messages of the channel as unread that were created after the specified timestamp.
1346+
///
1347+
/// This method finds the first message with a creation timestamp greater than to the provided timestamp,
1348+
/// and marks all messages from that point forward as unread. If no message is found after the timestamp,
1349+
/// the operation completes without error but no messages are marked as unread.
1350+
///
1351+
/// - Parameters:
1352+
/// - timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread.
1353+
/// - completion: The completion handler to be called after marking messages as unread. Called with a `Result` containing the updated `ChatChannel` on success, or an `Error` on failure.
1354+
public func markUnread(from timestamp: Date, completion: ((Result<ChatChannel, Error>) -> Void)? = nil) {
1355+
/// Perform action only if channel is already created on backend side and have a valid `cid`.
1356+
guard let channel = channel else {
1357+
let error = ClientError.ChannelNotCreatedYet()
1358+
log.error(error.localizedDescription)
1359+
callback {
1360+
completion?(.failure(error))
1361+
}
1362+
return
1363+
}
1364+
1365+
/// Read events are not enabled for this channel
1366+
guard channel.canReceiveReadEvents == true else {
1367+
let error = ClientError.ChannelFeatureDisabled("Channel feature: read events is disabled for this channel.")
1368+
log.error(error.localizedDescription)
1369+
callback {
1370+
completion?(.failure(error))
1371+
}
1372+
return
1373+
}
1374+
1375+
readStateHandler.markUnread(
1376+
from: .messageTimestamp(timestamp),
13371377
in: channel
13381378
) { [weak self] result in
13391379
self?.callback {

Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,23 +138,44 @@ extension NSManagedObjectContext {
138138
func markChannelAsUnread(
139139
for cid: ChannelId,
140140
userId: UserId,
141-
from messageId: MessageId,
141+
from unreadCriteria: MarkUnreadCriteria,
142142
lastReadMessageId: MessageId?,
143143
lastReadAt: Date?,
144144
unreadMessagesCount: Int?
145145
) {
146-
guard let read = loadChannelRead(cid: cid, userId: userId),
147-
let message = self.message(id: messageId) else {
148-
return
146+
guard let read = loadChannelRead(cid: cid, userId: userId) else { return }
147+
148+
let findMessageDTO: () -> MessageDTO? = {
149+
switch unreadCriteria {
150+
case .messageId(let messageId):
151+
return self.message(id: messageId)
152+
case .messageTimestamp(let messageTimestamp):
153+
let clientConfig = self.chatClientConfig
154+
return try? MessageDTO.loadMessage(
155+
beforeOrEqual: messageTimestamp,
156+
cid: cid.rawValue,
157+
deletedMessagesVisibility: clientConfig?.deletedMessagesVisibility ?? .alwaysVisible,
158+
shouldShowShadowedMessages: clientConfig?.shouldShowShadowedMessages ?? false,
159+
context: self
160+
)
161+
}
149162
}
163+
guard let message = findMessageDTO() else { return }
150164

151165
let lastReadAt = lastReadAt ?? message.createdAt.bridgeDate
152166
read.lastReadAt = lastReadAt.bridgeDate
153167
read.lastReadMessageId = lastReadMessageId
154168

169+
let excludesMessageId: Bool = {
170+
switch unreadCriteria {
171+
case .messageId: return false
172+
case .messageTimestamp: return true
173+
}
174+
}()
155175
let messagesCount = unreadMessagesCount ?? MessageDTO.countOtherUserMessages(
156176
in: read.channel.cid,
157177
createdAtFrom: lastReadAt,
178+
excludingMessageId: excludesMessageId ? message.id : nil,
158179
context: self
159180
)
160181
read.unreadMessageCount = Int32(messagesCount)

Sources/StreamChat/Database/DTOs/MessageDTO.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,13 +558,17 @@ class MessageDTO: NSManagedObject {
558558
static func countOtherUserMessages(
559559
in cid: String,
560560
createdAtFrom: Date,
561+
excludingMessageId: MessageId?,
561562
context: NSManagedObjectContext
562563
) -> Int {
563-
let subpredicates: [NSPredicate] = [
564+
var subpredicates: [NSPredicate] = [
564565
sentMessagesPredicate(for: cid),
565566
.init(format: "createdAt >= %@", createdAtFrom.bridgeDate),
566567
.init(format: "user.currentUser == nil")
567568
]
569+
if let excludingMessageId {
570+
subpredicates.append(.init(format: "id != %@", excludingMessageId))
571+
}
568572

569573
let request = NSFetchRequest<MessageDTO>(entityName: MessageDTO.entityName)
570574
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.defaultSortingKey, ascending: false)]
@@ -607,6 +611,23 @@ class MessageDTO: NSManagedObject {
607611
return try context.fetch(request).first
608612
}
609613

614+
static func loadMessage(
615+
beforeOrEqual timestamp: Date,
616+
cid: String,
617+
deletedMessagesVisibility: ChatClientConfig.DeletedMessageVisibility,
618+
shouldShowShadowedMessages: Bool,
619+
context: NSManagedObjectContext
620+
) throws -> MessageDTO? {
621+
let request = NSFetchRequest<MessageDTO>(entityName: entityName)
622+
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
623+
.init(format: "createdAt <= %@", timestamp.bridgeDate),
624+
channelMessagesPredicate(for: cid, deletedMessagesVisibility: deletedMessagesVisibility, shouldShowShadowedMessages: shouldShowShadowedMessages)
625+
])
626+
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)]
627+
request.fetchLimit = 1
628+
return try context.fetch(request).first
629+
}
630+
610631
static func loadMessages(
611632
from fromIncludingDate: Date,
612633
to toIncludingDate: Date,

Sources/StreamChat/Database/DatabaseSession.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,12 +396,12 @@ protocol ChannelReadDatabaseSession {
396396
/// Sets the channel `cid` as read for `userId`
397397
func markChannelAsRead(cid: ChannelId, userId: UserId, at: Date)
398398

399-
/// Sets the channel `cid` as unread for `userId` starting from the `messageId`
399+
/// Sets the channel `cid` as unread for `userId` starting from the message id or timestamp.
400400
/// Uses `lastReadAt` and `unreadMessagesCount` if passed, otherwise it calculates it.
401401
func markChannelAsUnread(
402402
for cid: ChannelId,
403403
userId: UserId,
404-
from messageId: MessageId,
404+
from unreadCriteria: MarkUnreadCriteria,
405405
lastReadMessageId: MessageId?,
406406
lastReadAt: Date?,
407407
unreadMessagesCount: Int?

Sources/StreamChat/Repositories/ChannelRepository.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,23 @@ class ChannelRepository {
5858
}
5959
}
6060

61-
/// Marks a subset of the messages of the channel as unread. All the following messages, including the one that is
61+
/// Marks a subset of the messages of the channel as unread. All the following messages including the one that is
6262
/// passed as parameter, will be marked as not read.
6363
/// - Parameters:
6464
/// - cid: The id of the channel to be marked as unread
6565
/// - userId: The id of the current user
66-
/// - messageId: The id of the first message that will be marked as unread.
66+
/// - unreadCriteria: The id or timestamp of the first message that will be marked as unread.
6767
/// - lastReadMessageId: The id of the last message that was read.
6868
/// - completion: Called when the API call is finished. Called with `Error` if the remote update fails.
6969
func markUnread(
7070
for cid: ChannelId,
7171
userId: UserId,
72-
from messageId: MessageId,
72+
from unreadCriteria: MarkUnreadCriteria,
7373
lastReadMessageId: MessageId?,
7474
completion: ((Result<ChatChannel, Error>) -> Void)? = nil
7575
) {
7676
apiClient.request(
77-
endpoint: .markUnread(cid: cid, messageId: messageId, userId: userId)
77+
endpoint: .markUnread(cid: cid, payload: .init(criteria: unreadCriteria, userId: userId))
7878
) { [weak self] result in
7979
if let error = result.error {
8080
completion?(.failure(error))
@@ -86,7 +86,7 @@ class ChannelRepository {
8686
session.markChannelAsUnread(
8787
for: cid,
8888
userId: userId,
89-
from: messageId,
89+
from: unreadCriteria,
9090
lastReadMessageId: lastReadMessageId,
9191
lastReadAt: nil,
9292
unreadMessagesCount: nil

Sources/StreamChat/Repositories/MessageRepository.swift

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ class MessageRepository {
339339

340340
/// Fetches a message id before the specified message when sorting by the creation date in the local database.
341341
func getMessage(
342-
before messageId: MessageId,
342+
before unreadCriteria: MarkUnreadCriteria,
343343
in cid: ChannelId,
344344
completion: @escaping (Result<MessageId?, Error>) -> Void
345345
) {
@@ -349,14 +349,26 @@ class MessageRepository {
349349
let deletedMessagesVisibility = clientConfig?.deletedMessagesVisibility ?? .alwaysVisible
350350
let shouldShowShadowedMessages = clientConfig?.shouldShowShadowedMessages ?? false
351351
do {
352-
let resultId = try MessageDTO.loadMessage(
353-
before: messageId,
354-
cid: cid.rawValue,
355-
deletedMessagesVisibility: deletedMessagesVisibility,
356-
shouldShowShadowedMessages: shouldShowShadowedMessages,
357-
context: context
358-
)?.id
359-
completion(.success(resultId))
352+
switch unreadCriteria {
353+
case .messageId(let messageId):
354+
let resultId = try MessageDTO.loadMessage(
355+
before: messageId,
356+
cid: cid.rawValue,
357+
deletedMessagesVisibility: deletedMessagesVisibility,
358+
shouldShowShadowedMessages: shouldShowShadowedMessages,
359+
context: context
360+
)?.id
361+
completion(.success(resultId))
362+
case .messageTimestamp(let messageTimestamp):
363+
let resultId = try MessageDTO.loadMessage(
364+
beforeOrEqual: messageTimestamp,
365+
cid: cid.rawValue,
366+
deletedMessagesVisibility: deletedMessagesVisibility,
367+
shouldShowShadowedMessages: shouldShowShadowedMessages,
368+
context: context
369+
)?.id
370+
completion(.success(resultId))
371+
}
360372
} catch {
361373
completion(.failure(error))
362374
}

0 commit comments

Comments
 (0)