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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### 🐞 Fixed
- Fix openChannel not working when searching or another chat shown [#975](https://github.com/GetStream/stream-chat-swiftui/pull/975)
- Fix crash when using a font that does not support bold or italic trait [#976](https://github.com/GetStream/stream-chat-swiftui/pull/976)
- Fix unread messages banner not shown for one-page channels [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
- Fix unread messages banner not shown if the whole channel is unread [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
- Fix channel not marking read when passing by the unread message [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
- Fix random scroll after marking a message unread [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
- Fix marking channel read when the user scrolls to the bottom after marking a message as unread [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
- Fix replying to unread messages marking them instantly as read [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)

# [4.89.1](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.89.1)
_September 23, 2025_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,17 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
}

var firstUnreadMessageId: String? {
controller.firstUnreadMessageId
if controller.firstUnreadMessageId == nil && controller.lastReadMessageId == nil {
let currentUserReadHasRead = controller.channel?.reads.first(where: {
$0.user.id == controller.client.currentUserId
}) != nil
// If the current user has unread state but no unread message is available
// it means the whole channel is unread, so the first message is the unread message.
if currentUserReadHasRead {
return controller.messages.last?.id
}
}
return controller.firstUnreadMessageId
}

init(controller: ChatChannelController) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}
}


// A boolean value indicating if the user marked a message as unread
// in the current session of the channel. If it is true,
// it should not call markRead() in any scenario.
public var currentUserMarkedMessageUnread: Bool = false

@Published public private(set) var channel: ChatChannel?

public var isMessageThread: Bool {
Expand Down Expand Up @@ -347,7 +352,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if utils.messageListConfig.dateIndicatorPlacement == .overlay {
save(lastDate: message.createdAt)
}
if index == 0, channelDataSource.hasLoadedAllNextMessages {
if channelDataSource.hasLoadedAllNextMessages {
let isActive = UIApplication.shared.applicationState == .active
if isActive && canMarkRead {
sendReadEventIfNeeded(for: message)
Expand Down Expand Up @@ -571,7 +576,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

private func sendReadEventIfNeeded(for message: ChatMessage) {
guard let channel, channel.unreadCount.messages > 0 else { return }
guard let channel, channel.unreadCount.messages > 0 else {
return
}
if currentUserMarkedMessageUnread {
return
}
throttler.execute { [weak self] in
self?.channelController.markRead()
// We keep `firstUnreadMessageId` value set which keeps showing the new messages header in the channel view
Expand Down Expand Up @@ -679,7 +689,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
canMarkRead = true

if channel.unreadCount.messages > 0 {
if channelController.firstUnreadMessageId != nil {
if channelDataSource.firstUnreadMessageId != nil {
firstUnreadMessageId = channelController.firstUnreadMessageId
canMarkRead = false
} else if channelController.lastReadMessageId != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class MessageActionsResolver: MessageActionsResolving {
}
} else if info.identifier == MessageActionId.markUnread {
viewModel.firstUnreadMessageId = info.message.messageId
viewModel.currentUserMarkedMessageUnread = true
viewModel.scrolledId = info.message.messageId
}

viewModel.reactionsShown = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,66 @@ class ChatChannelDataSource_Tests: StreamChatTestCase {
XCTAssert(noMessagesCall == false)
XCTAssert(messagesCall == true)
}

// MARK: - firstUnreadMessageId Tests

func test_channelDataSource_firstUnreadMessageId_whenControllerHasFirstUnreadMessageId() {
// Given
let firstUnreadMessageId = "first-unread-message-id"
let controller = makeChannelController(messages: [message])
controller.mockFirstUnreadMessageId = firstUnreadMessageId
let channelDataSource = ChatChannelDataSource(controller: controller)

// When
let result = channelDataSource.firstUnreadMessageId

// Then
XCTAssertEqual(result, firstUnreadMessageId)
}

func test_channelDataSource_firstUnreadMessageId_whenNilAndCurrentUserHasRead() {
// Given
let currentUserId = chatClient.currentUserId!
let read = ChatChannelRead.mock(
lastReadAt: Date(),
lastReadMessageId: nil,
unreadMessagesCount: 0,
user: .mock(id: currentUserId)
)
let channel = ChatChannel.mockDMChannel(reads: [read])
let controller = makeChannelController(messages: [.mock(), .mock(), message])
controller.channel_mock = channel
controller.mockFirstUnreadMessageId = nil
let channelDataSource = ChatChannelDataSource(controller: controller)

// When
let result = channelDataSource.firstUnreadMessageId

// Then
XCTAssertEqual(result, message.id)
}

func test_channelDataSource_firstUnreadMessageId_whenNilAndCurrentUserHasNotRead() {
// Given
let otherUserId = UserId.unique
let read = ChatChannelRead.mock(
lastReadAt: Date(),
lastReadMessageId: nil,
unreadMessagesCount: 0,
user: .mock(id: otherUserId)
)
let channel = ChatChannel.mockDMChannel(reads: [read])
let controller = makeChannelController(messages: [message])
controller.channel_mock = channel
controller.mockFirstUnreadMessageId = .unique
let channelDataSource = ChatChannelDataSource(controller: controller)

// When
let result = channelDataSource.firstUnreadMessageId

// Then
XCTAssertNotEqual(result, message.id)
}

// MARK: - private

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,81 @@ class ChatChannelViewModel_Tests: StreamChatTestCase {
XCTAssertEqual(1, channelController.markReadCallCount)
XCTAssertNotNil(viewModel.firstUnreadMessageId)
}

// MARK: - currentUserMarkedMessageUnread Tests

func test_chatChannelVM_currentUserMarkedMessageUnread_initialValue() {
// Given
let channelController = makeChannelController()
let viewModel = ChatChannelViewModel(channelController: channelController)

// Then
XCTAssertFalse(viewModel.currentUserMarkedMessageUnread)
}

func test_chatChannelVM_sendReadEventIfNeeded_whenCurrentUserMarkedMessageUnreadIsTrue() {
// Given
let message = ChatMessage.mock()
let channelController = makeChannelController(messages: [message])
channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
let viewModel = ChatChannelViewModel(channelController: channelController)
viewModel.currentUserMarkedMessageUnread = true
viewModel.throttler = Throttler_Mock(interval: 0)

// When
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)

// Then
XCTAssertEqual(0, channelController.markReadCallCount)
}

func test_chatChannelVM_sendReadEventIfNeeded_whenCurrentUserMarkedMessageUnreadIsFalse() {
// Given
let message = ChatMessage.mock()
let channelController = makeChannelController(messages: [message])
channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
let viewModel = ChatChannelViewModel(channelController: channelController)
viewModel.currentUserMarkedMessageUnread = false
viewModel.throttler = Throttler_Mock(interval: 0)

// When
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)

// Then
XCTAssertEqual(1, channelController.markReadCallCount)
}

func test_chatChannelVM_sendReadEventIfNeeded_whenChannelHasNoUnreadMessages() {
// Given
let message = ChatMessage.mock()
let channelController = makeChannelController(messages: [message])
channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 0, mentions: 0))
let viewModel = ChatChannelViewModel(channelController: channelController)
viewModel.currentUserMarkedMessageUnread = false
viewModel.throttler = Throttler_Mock(interval: 0)

// When
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)

// Then
XCTAssertEqual(0, channelController.markReadCallCount)
}

func test_chatChannelVM_sendReadEventIfNeeded_whenChannelIsNil() {
// Given
let message = ChatMessage.mock()
let channelController = makeChannelController(messages: [message])
channelController.channel_mock = nil
let viewModel = ChatChannelViewModel(channelController: channelController)
viewModel.currentUserMarkedMessageUnread = false
viewModel.throttler = Throttler_Mock(interval: 0)

// When
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)

// Then
XCTAssertEqual(0, channelController.markReadCallCount)
}

// MARK: - private

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,79 @@ class MessageActions_Tests: StreamChatTestCase {
XCTAssertTrue(messageActions.contains(where: { $0.title == "Delete Message" }))
}

// MARK: - MessageActionsResolver Tests

func test_messageActionsResolver_markUnreadAction() {
// Given
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
let channelController = makeChannelController(messages: [message])
let viewModel = ChatChannelViewModel(channelController: channelController)
let resolver = MessageActionsResolver()
let actionInfo = MessageActionInfo(message: message, identifier: MessageActionId.markUnread)

// When
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)

// Then
XCTAssertEqual(viewModel.firstUnreadMessageId, message.messageId)
XCTAssertTrue(viewModel.currentUserMarkedMessageUnread)
XCTAssertEqual(viewModel.scrolledId, message.messageId)
XCTAssertFalse(viewModel.reactionsShown)
}

func test_messageActionsResolver_inlineReplyAction() {
// Given
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
let channelController = makeChannelController(messages: [message])
let viewModel = ChatChannelViewModel(channelController: channelController)
let resolver = MessageActionsResolver()
let actionInfo = MessageActionInfo(message: message, identifier: "inlineReply")

// When
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)

// Then
XCTAssertEqual(viewModel.quotedMessage, message)
XCTAssertNil(viewModel.editedMessage)
XCTAssertFalse(viewModel.reactionsShown)
}

func test_messageActionsResolver_editAction() {
// Given
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
let channelController = makeChannelController(messages: [message])
let viewModel = ChatChannelViewModel(channelController: channelController)
let resolver = MessageActionsResolver()
let actionInfo = MessageActionInfo(message: message, identifier: "edit")

// When
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)

// Then
XCTAssertEqual(viewModel.editedMessage, message)
XCTAssertNil(viewModel.quotedMessage)
XCTAssertFalse(viewModel.reactionsShown)
}

func test_messageActionsResolver_unknownAction() {
// Given
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
let channelController = makeChannelController(messages: [message])
let viewModel = ChatChannelViewModel(channelController: channelController)
let resolver = MessageActionsResolver()
let actionInfo = MessageActionInfo(message: message, identifier: "unknown")

// When
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)

// Then
XCTAssertNil(viewModel.quotedMessage)
XCTAssertNil(viewModel.editedMessage)
XCTAssertNil(viewModel.firstUnreadMessageId)
XCTAssertFalse(viewModel.currentUserMarkedMessageUnread)
XCTAssertFalse(viewModel.reactionsShown)
}

// MARK: - Private

private var mockDMChannel: ChatChannel {
Expand All @@ -415,4 +488,17 @@ class MessageActions_Tests: StreamChatTestCase {
]
)
}

private func makeChannelController(messages: [ChatMessage] = []) -> ChatChannelController_Mock {
let channelController = ChatChannelTestHelpers.makeChannelController(
chatClient: chatClient,
messages: messages
)
channelController.simulateInitial(
channel: .mockDMChannel(),
messages: messages,
state: .initialized
)
return channelController
}
}
Loading