Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- 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)
- Add support for filter tags in channels [#3886](https://github.com/GetStream/stream-chat-swift/pull/3886)
- Add `ChatChannel.filterTags`
- Add `filterTags` channel list filtering key
Expand Down
18 changes: 18 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,24 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
.init(title: "Mark channel unread with timestamp", isEnabled: true, handler: { [unowned self] _ in
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
let calendar = Calendar.current
guard let offsetInDays = Int(offsetInDaysString ?? ""),
let date = calendar.date(byAdding: .day, value: -abs(offsetInDays), to: calendar.startOfDay(for: Date())) else {
self.rootViewController.presentAlert(title: "Timestamp offset is not valid")
return
}
channelController.markUnread(from: date) { result in
switch result {
case .failure(let error):
self.rootViewController.presentAlert(title: "Couldn't mark messages as unread \(cid)", message: "\(error)")
case .success:
break
}
}
}
}),
.init(title: "Cool channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
channelController.partialChannelUpdate(extraData: ["is_cool": true]) { error in
if let error = error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,16 +269,13 @@ extension Endpoint {
)
}

static func markUnread(cid: ChannelId, messageId: MessageId, userId: UserId) -> Endpoint<EmptyResponse> {
static func markUnread(cid: ChannelId, payload: MarkUnreadPayload) -> Endpoint<EmptyResponse> {
.init(
path: .markChannelUnread(cid.apiPath),
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: [
"message_id": messageId,
"user_id": userId
]
body: payload
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

enum MarkUnreadCriteria: Sendable, Equatable {
/// The ID of the message from where the channel is marked unread
case messageId(String)
/// The timestamp of the message from where the channel is marked unread
case messageTimestamp(Date)
}

struct MarkUnreadPayload: Encodable, Sendable {
let criteria: MarkUnreadCriteria
let userId: String

func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(userId, forKey: .userId)
switch criteria {
case .messageId(let messageId):
try container.encode(messageId, forKey: .messageId)
case .messageTimestamp(let messageTimestamp):
try container.encode(messageTimestamp, forKey: .messageTimestamp)
}
}

private enum CodingKeys: String, CodingKey {
case messageId = "message_id"
case messageTimestamp = "message_timestamp"
case userId = "user_id"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1333,7 +1333,47 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
}

readStateHandler.markUnread(
from: messageId,
from: .messageId(messageId),
in: channel
) { [weak self] result in
self?.callback {
completion?(result)
}
}
}

/// Marks all messages of the channel as unread that were created after the specified timestamp.
///
/// This method finds the first message with a creation timestamp greater than to the provided timestamp,
/// and marks all messages from that point forward as unread. If no message is found after the timestamp,
/// the operation completes without error but no messages are marked as unread.
///
/// - Parameters:
/// - timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread.
/// - 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.
public func markUnread(from timestamp: Date, completion: ((Result<ChatChannel, Error>) -> Void)? = nil) {
/// Perform action only if channel is already created on backend side and have a valid `cid`.
guard let channel = channel else {
let error = ClientError.ChannelNotCreatedYet()
log.error(error.localizedDescription)
callback {
completion?(.failure(error))
}
return
}

/// Read events are not enabled for this channel
guard channel.canReceiveReadEvents == true else {
let error = ClientError.ChannelFeatureDisabled("Channel feature: read events is disabled for this channel.")
log.error(error.localizedDescription)
callback {
completion?(.failure(error))
}
return
}

readStateHandler.markUnread(
from: .messageTimestamp(timestamp),
in: channel
) { [weak self] result in
self?.callback {
Expand Down
22 changes: 18 additions & 4 deletions Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,29 @@ extension NSManagedObjectContext {
func markChannelAsUnread(
for cid: ChannelId,
userId: UserId,
from messageId: MessageId,
from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
lastReadAt: Date?,
unreadMessagesCount: Int?
) {
guard let read = loadChannelRead(cid: cid, userId: userId),
let message = self.message(id: messageId) else {
return
guard let read = loadChannelRead(cid: cid, userId: userId) else { return }

let findMessageDTO: () -> MessageDTO? = {
switch unreadCriteria {
case .messageId(let messageId):
return self.message(id: messageId)
case .messageTimestamp(let messageTimestamp):
let clientConfig = self.chatClientConfig
return try? MessageDTO.loadMessage(
beforeOrEqual: messageTimestamp,
cid: cid.rawValue,
deletedMessagesVisibility: clientConfig?.deletedMessagesVisibility ?? .alwaysVisible,
shouldShowShadowedMessages: clientConfig?.shouldShowShadowedMessages ?? false,
context: self
)
}
}
guard let message = findMessageDTO() else { return }

let lastReadAt = lastReadAt ?? message.createdAt.bridgeDate
read.lastReadAt = lastReadAt.bridgeDate
Expand Down
17 changes: 17 additions & 0 deletions Sources/StreamChat/Database/DTOs/MessageDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,23 @@ class MessageDTO: NSManagedObject {
return try context.fetch(request).first
}

static func loadMessage(
beforeOrEqual timestamp: Date,
cid: String,
deletedMessagesVisibility: ChatClientConfig.DeletedMessageVisibility,
shouldShowShadowedMessages: Bool,
context: NSManagedObjectContext
) throws -> MessageDTO? {
let request = NSFetchRequest<MessageDTO>(entityName: entityName)
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
.init(format: "createdAt <= %@", timestamp.bridgeDate),
channelMessagesPredicate(for: cid, deletedMessagesVisibility: deletedMessagesVisibility, shouldShowShadowedMessages: shouldShowShadowedMessages)
])
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)]
request.fetchLimit = 1
return try context.fetch(request).first
}

static func loadMessages(
from fromIncludingDate: Date,
to toIncludingDate: Date,
Expand Down
4 changes: 2 additions & 2 deletions Sources/StreamChat/Database/DatabaseSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,12 +396,12 @@ protocol ChannelReadDatabaseSession {
/// Sets the channel `cid` as read for `userId`
func markChannelAsRead(cid: ChannelId, userId: UserId, at: Date)

/// Sets the channel `cid` as unread for `userId` starting from the `messageId`
/// Sets the channel `cid` as unread for `userId` starting from the message id or timestamp.
/// Uses `lastReadAt` and `unreadMessagesCount` if passed, otherwise it calculates it.
func markChannelAsUnread(
for cid: ChannelId,
userId: UserId,
from messageId: MessageId,
from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
lastReadAt: Date?,
unreadMessagesCount: Int?
Expand Down
10 changes: 5 additions & 5 deletions Sources/StreamChat/Repositories/ChannelRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,23 @@ class ChannelRepository {
}
}

/// Marks a subset of the messages of the channel as unread. All the following messages, including the one that is
/// Marks a subset of the messages of the channel as unread. All the following messages including the one that is
/// passed as parameter, will be marked as not read.
/// - Parameters:
/// - cid: The id of the channel to be marked as unread
/// - userId: The id of the current user
/// - messageId: The id of the first message that will be marked as unread.
/// - unreadCriteria: The id or timestamp of the first message that will be marked as unread.
/// - lastReadMessageId: The id of the last message that was read.
/// - completion: Called when the API call is finished. Called with `Error` if the remote update fails.
func markUnread(
for cid: ChannelId,
userId: UserId,
from messageId: MessageId,
from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
completion: ((Result<ChatChannel, Error>) -> Void)? = nil
) {
apiClient.request(
endpoint: .markUnread(cid: cid, messageId: messageId, userId: userId)
endpoint: .markUnread(cid: cid, payload: .init(criteria: unreadCriteria, userId: userId))
) { [weak self] result in
if let error = result.error {
completion?(.failure(error))
Expand All @@ -86,7 +86,7 @@ class ChannelRepository {
session.markChannelAsUnread(
for: cid,
userId: userId,
from: messageId,
from: unreadCriteria,
lastReadMessageId: lastReadMessageId,
lastReadAt: nil,
unreadMessagesCount: nil
Expand Down
30 changes: 21 additions & 9 deletions Sources/StreamChat/Repositories/MessageRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ class MessageRepository {

/// Fetches a message id before the specified message when sorting by the creation date in the local database.
func getMessage(
before messageId: MessageId,
before unreadCriteria: MarkUnreadCriteria,
in cid: ChannelId,
completion: @escaping (Result<MessageId?, Error>) -> Void
) {
Expand All @@ -349,14 +349,26 @@ class MessageRepository {
let deletedMessagesVisibility = clientConfig?.deletedMessagesVisibility ?? .alwaysVisible
let shouldShowShadowedMessages = clientConfig?.shouldShowShadowedMessages ?? false
do {
let resultId = try MessageDTO.loadMessage(
before: messageId,
cid: cid.rawValue,
deletedMessagesVisibility: deletedMessagesVisibility,
shouldShowShadowedMessages: shouldShowShadowedMessages,
context: context
)?.id
completion(.success(resultId))
switch unreadCriteria {
case .messageId(let messageId):
let resultId = try MessageDTO.loadMessage(
before: messageId,
cid: cid.rawValue,
deletedMessagesVisibility: deletedMessagesVisibility,
shouldShowShadowedMessages: shouldShowShadowedMessages,
context: context
)?.id
completion(.success(resultId))
case .messageTimestamp(let messageTimestamp):
let resultId = try MessageDTO.loadMessage(
beforeOrEqual: messageTimestamp,
cid: cid.rawValue,
deletedMessagesVisibility: deletedMessagesVisibility,
shouldShowShadowedMessages: shouldShowShadowedMessages,
context: context
)?.id
completion(.success(resultId))
}
} catch {
completion(.failure(error))
}
Expand Down
12 changes: 11 additions & 1 deletion Sources/StreamChat/StateLayer/Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,17 @@ public class Chat {
/// - Throws: An error while communicating with the Stream API.
public func markUnread(from messageId: MessageId) async throws {
guard let channel = await state.channel else { throw ClientError.ChannelNotCreatedYet() }
try await readStateHandler.markUnread(from: messageId, in: channel)
try await readStateHandler.markUnread(from: .messageId(messageId), in: channel)
}

/// Marks all the messages after the specified timestamp as unread.
///
/// - Parameter timestamp: The timestamp used to find the first message to mark as unread. All messages created after this timestamp will be marked as unread.
///
/// - Throws: An error while communicating with the Stream API.
public func markUnread(from timestamp: Date) async throws {
guard let channel = await state.channel else { throw ClientError.ChannelNotCreatedYet() }
try await readStateHandler.markUnread(from: .messageTimestamp(timestamp), in: channel)
}

// MARK: - Message Replies and Pagination
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware {
session.markChannelAsUnread(
for: cid,
userId: userId,
from: messageId,
from: .messageId(messageId),
lastReadMessageId: lastReadMessageId,
lastReadAt: lastReadAt,
unreadMessagesCount: unreadMessages
Expand Down
6 changes: 3 additions & 3 deletions Sources/StreamChat/Workers/ChannelUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -564,20 +564,20 @@ class ChannelUpdater: Worker {
/// - Parameters:
/// - cid: The id of the channel to be marked as unread
/// - userId: The id of the current user
/// - messageId: The id of the first message id that will be marked as unread.
/// - unreadCriteria: The id or timestamp of the first message that will be marked as unread.
/// - lastReadMessageId: The id of the last message that was read.
/// - completion: Called when the API call is finished. Called with `Error` if the remote update fails.
func markUnread(
cid: ChannelId,
userId: UserId,
from messageId: MessageId,
from unreadCriteria: MarkUnreadCriteria,
lastReadMessageId: MessageId?,
completion: ((Result<ChatChannel, Error>) -> Void)? = nil
) {
channelRepository.markUnread(
for: cid,
userId: userId,
from: messageId,
from: unreadCriteria,
lastReadMessageId: lastReadMessageId,
completion: completion
)
Expand Down
10 changes: 5 additions & 5 deletions Sources/StreamChat/Workers/ReadStateHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ final class ReadStateHandler {
}

func markUnread(
from messageId: MessageId,
from unreadCriteria: MarkUnreadCriteria,
in channel: ChatChannel,
completion: @escaping (Result<ChatChannel, Error>) -> Void
) {
Expand All @@ -57,13 +57,13 @@ final class ReadStateHandler {
return
}
markingRead = true
messageRepository.getMessage(before: messageId, in: channel.cid) { [weak self] result in
messageRepository.getMessage(before: unreadCriteria, in: channel.cid) { [weak self] result in
switch result {
case .success(let lastReadMessageId):
self?.channelUpdater.markUnread(
cid: channel.cid,
userId: currentUserId,
from: messageId,
from: unreadCriteria,
lastReadMessageId: lastReadMessageId
) { [weak self] result in
if case .success = result {
Expand All @@ -80,12 +80,12 @@ final class ReadStateHandler {
}

func markUnread(
from messageId: MessageId,
from unreadCriteria: MarkUnreadCriteria,
in channel: ChatChannel
) async throws {
try await withCheckedThrowingContinuation { continuation in
markUnread(
from: messageId,
from: unreadCriteria,
in: channel
) { result in
continuation.resume(with: result.error)
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,10 @@ open class ChatChannelVC: _ViewController,
if let event = event as? MessageDeliveredEvent, event.cid == channelController.cid, !messages.isEmpty {
messageListVC.listView.reloadRows(at: [.init(item: 0, section: 0)], with: .none)
}

if let event = event as? NotificationMarkUnreadEvent, let channel = channelController.channel, event.cid == channelController.cid, !messages.isEmpty {
updateAllUnreadMessagesRelatedComponents(channel: channel)
}
Comment on lines +602 to +604
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing mark unread message action is implemented in ChatChannelVC and it explicitly calls this method in the completion handler. Since I added demo app debug command then this did not get called and unread separator did not appear.
Should I clean up the explicit call? At the moment I did not touch it.

case is MarkUnreadActionItem:
            dismiss(animated: true) { [weak self] in
                self?.channelController.markUnread(from: message.id) { result in
                    if case let .success(channel) = result {
                        self?.updateAllUnreadMessagesRelatedComponents(channel: channel)
                    }
                }
            }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ok imo, we have one sample implementation (customers can change/decide which one to use).

}

// MARK: - AudioQueuePlayerDatasource
Expand Down
Loading
Loading