Skip to content

Commit 5beb176

Browse files
authored
Fix multiple issues around mark unread and unread banner (#989)
* Fix the unread banner not shown if the whole channel is unread * Fix channel marking read when the user just marked a message as unread * Fix channel not marking read when passing by the unread message * Fix marking a message unread causing the message list to randomly scroll * Add test coverage to unread banner not shown when whole channel is unread * Add test coverage to marking channel read after setting a message unread * Update CHANGELOG.md * Add comment explaining new logic of firstUnreadMessageId * Update CHANGELOG.md
1 parent 6cf0a57 commit 5beb176

File tree

7 files changed

+254
-5
lines changed

7 files changed

+254
-5
lines changed

CHANGELOG.md

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

1622
# [4.89.1](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.89.1)
1723
_September 23, 2025_

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,17 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
9191
}
9292

9393
var firstUnreadMessageId: String? {
94-
controller.firstUnreadMessageId
94+
if controller.firstUnreadMessageId == nil && controller.lastReadMessageId == nil {
95+
let currentUserReadHasRead = controller.channel?.reads.first(where: {
96+
$0.user.id == controller.client.currentUserId
97+
}) != nil
98+
// If the current user has unread state but no unread message is available
99+
// it means the whole channel is unread, so the first message is the unread message.
100+
if currentUserReadHasRead {
101+
return controller.messages.last?.id
102+
}
103+
}
104+
return controller.firstUnreadMessageId
95105
}
96106

97107
init(controller: ChatChannelController) {

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
128128
}
129129
}
130130
}
131-
131+
132+
// A boolean value indicating if the user marked a message as unread
133+
// in the current session of the channel. If it is true,
134+
// it should not call markRead() in any scenario.
135+
public var currentUserMarkedMessageUnread: Bool = false
136+
132137
@Published public private(set) var channel: ChatChannel?
133138

134139
public var isMessageThread: Bool {
@@ -347,7 +352,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
347352
if utils.messageListConfig.dateIndicatorPlacement == .overlay {
348353
save(lastDate: message.createdAt)
349354
}
350-
if index == 0, channelDataSource.hasLoadedAllNextMessages {
355+
if channelDataSource.hasLoadedAllNextMessages {
351356
let isActive = UIApplication.shared.applicationState == .active
352357
if isActive && canMarkRead {
353358
sendReadEventIfNeeded(for: message)
@@ -571,7 +576,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
571576
}
572577

573578
private func sendReadEventIfNeeded(for message: ChatMessage) {
574-
guard let channel, channel.unreadCount.messages > 0 else { return }
579+
guard let channel, channel.unreadCount.messages > 0 else {
580+
return
581+
}
582+
if currentUserMarkedMessageUnread {
583+
return
584+
}
575585
throttler.execute { [weak self] in
576586
self?.channelController.markRead()
577587
// We keep `firstUnreadMessageId` value set which keeps showing the new messages header in the channel view
@@ -679,7 +689,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
679689
canMarkRead = true
680690

681691
if channel.unreadCount.messages > 0 {
682-
if channelController.firstUnreadMessageId != nil {
692+
if channelDataSource.firstUnreadMessageId != nil {
683693
firstUnreadMessageId = channelController.firstUnreadMessageId
684694
canMarkRead = false
685695
} else if channelController.lastReadMessageId != nil {

Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public class MessageActionsResolver: MessageActionsResolving {
3939
}
4040
} else if info.identifier == MessageActionId.markUnread {
4141
viewModel.firstUnreadMessageId = info.message.messageId
42+
viewModel.currentUserMarkedMessageUnread = true
43+
viewModel.scrolledId = info.message.messageId
4244
}
4345

4446
viewModel.reactionsShown = false

StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,66 @@ class ChatChannelDataSource_Tests: StreamChatTestCase {
165165
XCTAssert(noMessagesCall == false)
166166
XCTAssert(messagesCall == true)
167167
}
168+
169+
// MARK: - firstUnreadMessageId Tests
170+
171+
func test_channelDataSource_firstUnreadMessageId_whenControllerHasFirstUnreadMessageId() {
172+
// Given
173+
let firstUnreadMessageId = "first-unread-message-id"
174+
let controller = makeChannelController(messages: [message])
175+
controller.mockFirstUnreadMessageId = firstUnreadMessageId
176+
let channelDataSource = ChatChannelDataSource(controller: controller)
177+
178+
// When
179+
let result = channelDataSource.firstUnreadMessageId
180+
181+
// Then
182+
XCTAssertEqual(result, firstUnreadMessageId)
183+
}
184+
185+
func test_channelDataSource_firstUnreadMessageId_whenNilAndCurrentUserHasRead() {
186+
// Given
187+
let currentUserId = chatClient.currentUserId!
188+
let read = ChatChannelRead.mock(
189+
lastReadAt: Date(),
190+
lastReadMessageId: nil,
191+
unreadMessagesCount: 0,
192+
user: .mock(id: currentUserId)
193+
)
194+
let channel = ChatChannel.mockDMChannel(reads: [read])
195+
let controller = makeChannelController(messages: [.mock(), .mock(), message])
196+
controller.channel_mock = channel
197+
controller.mockFirstUnreadMessageId = nil
198+
let channelDataSource = ChatChannelDataSource(controller: controller)
199+
200+
// When
201+
let result = channelDataSource.firstUnreadMessageId
202+
203+
// Then
204+
XCTAssertEqual(result, message.id)
205+
}
206+
207+
func test_channelDataSource_firstUnreadMessageId_whenNilAndCurrentUserHasNotRead() {
208+
// Given
209+
let otherUserId = UserId.unique
210+
let read = ChatChannelRead.mock(
211+
lastReadAt: Date(),
212+
lastReadMessageId: nil,
213+
unreadMessagesCount: 0,
214+
user: .mock(id: otherUserId)
215+
)
216+
let channel = ChatChannel.mockDMChannel(reads: [read])
217+
let controller = makeChannelController(messages: [message])
218+
controller.channel_mock = channel
219+
controller.mockFirstUnreadMessageId = .unique
220+
let channelDataSource = ChatChannelDataSource(controller: controller)
221+
222+
// When
223+
let result = channelDataSource.firstUnreadMessageId
224+
225+
// Then
226+
XCTAssertNotEqual(result, message.id)
227+
}
168228

169229
// MARK: - private
170230

StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,81 @@ class ChatChannelViewModel_Tests: StreamChatTestCase {
584584
XCTAssertEqual(1, channelController.markReadCallCount)
585585
XCTAssertNotNil(viewModel.firstUnreadMessageId)
586586
}
587+
588+
// MARK: - currentUserMarkedMessageUnread Tests
589+
590+
func test_chatChannelVM_currentUserMarkedMessageUnread_initialValue() {
591+
// Given
592+
let channelController = makeChannelController()
593+
let viewModel = ChatChannelViewModel(channelController: channelController)
594+
595+
// Then
596+
XCTAssertFalse(viewModel.currentUserMarkedMessageUnread)
597+
}
598+
599+
func test_chatChannelVM_sendReadEventIfNeeded_whenCurrentUserMarkedMessageUnreadIsTrue() {
600+
// Given
601+
let message = ChatMessage.mock()
602+
let channelController = makeChannelController(messages: [message])
603+
channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
604+
let viewModel = ChatChannelViewModel(channelController: channelController)
605+
viewModel.currentUserMarkedMessageUnread = true
606+
viewModel.throttler = Throttler_Mock(interval: 0)
607+
608+
// When
609+
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
610+
611+
// Then
612+
XCTAssertEqual(0, channelController.markReadCallCount)
613+
}
614+
615+
func test_chatChannelVM_sendReadEventIfNeeded_whenCurrentUserMarkedMessageUnreadIsFalse() {
616+
// Given
617+
let message = ChatMessage.mock()
618+
let channelController = makeChannelController(messages: [message])
619+
channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
620+
let viewModel = ChatChannelViewModel(channelController: channelController)
621+
viewModel.currentUserMarkedMessageUnread = false
622+
viewModel.throttler = Throttler_Mock(interval: 0)
623+
624+
// When
625+
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
626+
627+
// Then
628+
XCTAssertEqual(1, channelController.markReadCallCount)
629+
}
630+
631+
func test_chatChannelVM_sendReadEventIfNeeded_whenChannelHasNoUnreadMessages() {
632+
// Given
633+
let message = ChatMessage.mock()
634+
let channelController = makeChannelController(messages: [message])
635+
channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 0, mentions: 0))
636+
let viewModel = ChatChannelViewModel(channelController: channelController)
637+
viewModel.currentUserMarkedMessageUnread = false
638+
viewModel.throttler = Throttler_Mock(interval: 0)
639+
640+
// When
641+
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
642+
643+
// Then
644+
XCTAssertEqual(0, channelController.markReadCallCount)
645+
}
646+
647+
func test_chatChannelVM_sendReadEventIfNeeded_whenChannelIsNil() {
648+
// Given
649+
let message = ChatMessage.mock()
650+
let channelController = makeChannelController(messages: [message])
651+
channelController.channel_mock = nil
652+
let viewModel = ChatChannelViewModel(channelController: channelController)
653+
viewModel.currentUserMarkedMessageUnread = false
654+
viewModel.throttler = Throttler_Mock(interval: 0)
655+
656+
// When
657+
viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
658+
659+
// Then
660+
XCTAssertEqual(0, channelController.markReadCallCount)
661+
}
587662

588663
// MARK: - private
589664

StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,79 @@ class MessageActions_Tests: StreamChatTestCase {
401401
XCTAssertTrue(messageActions.contains(where: { $0.title == "Delete Message" }))
402402
}
403403

404+
// MARK: - MessageActionsResolver Tests
405+
406+
func test_messageActionsResolver_markUnreadAction() {
407+
// Given
408+
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
409+
let channelController = makeChannelController(messages: [message])
410+
let viewModel = ChatChannelViewModel(channelController: channelController)
411+
let resolver = MessageActionsResolver()
412+
let actionInfo = MessageActionInfo(message: message, identifier: MessageActionId.markUnread)
413+
414+
// When
415+
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
416+
417+
// Then
418+
XCTAssertEqual(viewModel.firstUnreadMessageId, message.messageId)
419+
XCTAssertTrue(viewModel.currentUserMarkedMessageUnread)
420+
XCTAssertEqual(viewModel.scrolledId, message.messageId)
421+
XCTAssertFalse(viewModel.reactionsShown)
422+
}
423+
424+
func test_messageActionsResolver_inlineReplyAction() {
425+
// Given
426+
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
427+
let channelController = makeChannelController(messages: [message])
428+
let viewModel = ChatChannelViewModel(channelController: channelController)
429+
let resolver = MessageActionsResolver()
430+
let actionInfo = MessageActionInfo(message: message, identifier: "inlineReply")
431+
432+
// When
433+
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
434+
435+
// Then
436+
XCTAssertEqual(viewModel.quotedMessage, message)
437+
XCTAssertNil(viewModel.editedMessage)
438+
XCTAssertFalse(viewModel.reactionsShown)
439+
}
440+
441+
func test_messageActionsResolver_editAction() {
442+
// Given
443+
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
444+
let channelController = makeChannelController(messages: [message])
445+
let viewModel = ChatChannelViewModel(channelController: channelController)
446+
let resolver = MessageActionsResolver()
447+
let actionInfo = MessageActionInfo(message: message, identifier: "edit")
448+
449+
// When
450+
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
451+
452+
// Then
453+
XCTAssertEqual(viewModel.editedMessage, message)
454+
XCTAssertNil(viewModel.quotedMessage)
455+
XCTAssertFalse(viewModel.reactionsShown)
456+
}
457+
458+
func test_messageActionsResolver_unknownAction() {
459+
// Given
460+
let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
461+
let channelController = makeChannelController(messages: [message])
462+
let viewModel = ChatChannelViewModel(channelController: channelController)
463+
let resolver = MessageActionsResolver()
464+
let actionInfo = MessageActionInfo(message: message, identifier: "unknown")
465+
466+
// When
467+
resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
468+
469+
// Then
470+
XCTAssertNil(viewModel.quotedMessage)
471+
XCTAssertNil(viewModel.editedMessage)
472+
XCTAssertNil(viewModel.firstUnreadMessageId)
473+
XCTAssertFalse(viewModel.currentUserMarkedMessageUnread)
474+
XCTAssertFalse(viewModel.reactionsShown)
475+
}
476+
404477
// MARK: - Private
405478

406479
private var mockDMChannel: ChatChannel {
@@ -415,4 +488,17 @@ class MessageActions_Tests: StreamChatTestCase {
415488
]
416489
)
417490
}
491+
492+
private func makeChannelController(messages: [ChatMessage] = []) -> ChatChannelController_Mock {
493+
let channelController = ChatChannelTestHelpers.makeChannelController(
494+
chatClient: chatClient,
495+
messages: messages
496+
)
497+
channelController.simulateInitial(
498+
channel: .mockDMChannel(),
499+
messages: messages,
500+
state: .initialized
501+
)
502+
return channelController
503+
}
418504
}

0 commit comments

Comments
 (0)