Skip to content

Commit

Permalink
Add jump to unread interaction (#2894)
Browse files Browse the repository at this point in the history
* Add Jump to Unread Button Interaction

* Fix showing 0 unread count in jump to unread button

* Add test coverage

* Add `Components.shouldJumpToUnreadWhenOpeningChannel`

* Update CHANGELOG.md

* Only throttle mark unread when scrolling or messages are updated

* Fix Jump to Unread Button not hiding instantly when tapping discard

* Fix Xcode 14 Build

* Update CHANGELOG.md

* Update CHANGELOG.md

* Flaky test

---------

Co-authored-by: Alexey Alter-Pesotskiy <[email protected]>
  • Loading branch information
nuno-vieira and testableapple authored Nov 17, 2023
1 parent 938abe5 commit 57d1b85
Show file tree
Hide file tree
Showing 13 changed files with 193 additions and 39 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### ✅ Added
- Add new `NewMessageErrorEvent` when observing `EventsController` [#2885](https://github.com/GetStream/stream-chat-swift/pull/2885)

## StreamChatUI
### ✅ Added
- Add jump to unread messages interaction [#2894](https://github.com/GetStream/stream-chat-swift/pull/2894)
- Add support for opening a channel in the unread messages page with `Components.shouldJumpToUnreadWhenOpeningChannel` [#2894](https://github.com/GetStream/stream-chat-swift/pull/2894)

## StreamChatUI
### 🐞 Fixed
- Fix Message List UI not updated when message.updatedAt changes [#2884](https://github.com/GetStream/stream-chat-swift/pull/2884)
- Fix jump to unread button showing "0" unread counts [#2894](https://github.com/GetStream/stream-chat-swift/pull/2894)

# [4.42.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.42.0)
_November 14, 2023_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class AppConfigViewController: UITableViewController {
case isUniqueReactionsEnabled
case shouldMessagesStartAtTheTop
case shouldAnimateJumpToMessageWhenOpeningChannel
case shouldJumpToUnreadWhenOpeningChannel
case threadRepliesStartFromOldest
case threadRendersParentMessageEnabled
case isVoiceRecordingEnabled
Expand Down Expand Up @@ -364,6 +365,10 @@ class AppConfigViewController: UITableViewController {
cell.accessoryView = makeSwitchButton(Components.default.shouldAnimateJumpToMessageWhenOpeningChannel) { newValue in
Components.default.shouldAnimateJumpToMessageWhenOpeningChannel = newValue
}
case .shouldJumpToUnreadWhenOpeningChannel:
cell.accessoryView = makeSwitchButton(Components.default.shouldJumpToUnreadWhenOpeningChannel) { newValue in
Components.default.shouldJumpToUnreadWhenOpeningChannel = newValue
}
case .threadRepliesStartFromOldest:
cell.accessoryView = makeSwitchButton(Components.default.threadRepliesStartFromOldest) { newValue in
Components.default.threadRepliesStartFromOldest = newValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
getFirstUnreadMessageId()
}

/// The id of the message which the current user last read.
public var lastReadMessageId: MessageId? {
guard let currentUserRead = channel?.reads.first(where: {
$0.user.id == client.currentUserId
}) else {
return nil
}

guard let lastReadMessageId = currentUserRead.lastReadMessageId else {
return nil
}

return lastReadMessageId
}

/// A boolean indicating if the user marked the channel as unread in the current session
public private(set) var isMarkedAsUnread: Bool = false

Expand Down
48 changes: 29 additions & 19 deletions Sources/StreamChatUI/ChatChannel/ChatChannelVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ open class ChatChannelVC: _ViewController,
InvertedScrollViewPaginationHandler.make(scrollView: messageListVC.listView)
}()

var throttler: Throttler = Throttler(interval: 3, broadcastLatestEvent: true)
var throttler: Throttler = Throttler(interval: 3, queue: .main)

/// Determines if a messaged had been marked as unread in the current session
private var hasMarkedMessageAsUnread: Bool {
Expand All @@ -119,7 +119,7 @@ open class ChatChannelVC: _ViewController,
/// The id of the first unread message
private var firstUnreadMessageId: MessageId?

/// In case the given around message id is from a thread, we need to jump to the parent message and then the reply.
/// In case the given around message id is from a thread, we need to jump to the parent message and then the reply.
private var initialReplyId: MessageId?

override open func setUp() {
Expand Down Expand Up @@ -241,35 +241,34 @@ open class ChatChannelVC: _ViewController,
setChannelControllerToComposerIfNeeded(cid: channelController.cid)
messageComposerVC.updateContent()

updateAllUnreadMessagesRelatedComponents()

if let messageId = channelController.channelQuery.pagination?.parameter?.aroundMessageId {
// Jump to a message when opening the channel.
jumpToMessage(id: messageId, animated: components.shouldAnimateJumpToMessageWhenOpeningChannel)
}

if let replyId = initialReplyId {
} else if let replyId = initialReplyId {
// Jump to a parent message when opening the channel, and then to the reply.
// The delay is necessary so that the animation does not happen to quickly.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.jumpToMessage(
id: replyId,
animated: self.components.shouldAnimateJumpToMessageWhenOpeningChannel
)
}
} else if components.shouldJumpToUnreadWhenOpeningChannel {
// Jump to the unread message.
messageListVC.jumpToUnreadMessage(animated: components.shouldAnimateJumpToMessageWhenOpeningChannel)
}
updateAllUnreadMessagesRelatedComponents()
}

// MARK: - Actions

/// Marks the channel read and updates the UI optimistically.
public func markRead() {
throttler.throttle { [weak self] in
self?.channelController.markRead { error in
if error == nil {
self?.hasSeenLastMessage = false
}
self?.updateJumpToUnreadRelatedComponents()
self?.updateScrollToBottomButtonCount()
}
}
channelController.markRead()
hasSeenLastMessage = false
updateJumpToUnreadRelatedComponents()
updateScrollToBottomButtonCount()
}

/// Jump to a given message.
Expand Down Expand Up @@ -339,7 +338,10 @@ open class ChatChannelVC: _ViewController,
return
}

channelController.loadPageAroundMessageId(messageId, completion: completion)
channelController.loadPageAroundMessageId(messageId) { [weak self] error in
self?.updateJumpToUnreadRelatedComponents()
completion(error)
}
}

open func chatMessageListVCShouldLoadFirstPage(
Expand Down Expand Up @@ -408,7 +410,9 @@ open class ChatChannelVC: _ViewController,
hasSeenLastMessage = true
}
if shouldMarkChannelRead {
markRead()
throttler.execute { [weak self] in
self?.markRead()
}
}
}

Expand Down Expand Up @@ -468,7 +472,9 @@ open class ChatChannelVC: _ViewController,

self.updateJumpToUnreadRelatedComponents()
if self.shouldMarkChannelRead {
self.markRead()
self.throttler.execute {
self.markRead()
}
} else if !self.hasSeenFirstUnreadMessage {
self.updateUnreadMessagesBannerRelatedComponents()
}
Expand All @@ -480,6 +486,7 @@ open class ChatChannelVC: _ViewController,
didUpdateChannel channel: EntityChange<ChatChannel>
) {
updateScrollToBottomButtonCount()
updateJumpToUnreadRelatedComponents()

if headerView.channelController == nil, let cid = channelController.cid {
headerView.channelController = client.channelController(for: cid)
Expand Down Expand Up @@ -575,7 +582,10 @@ private extension ChatChannelVC {
}

func updateJumpToUnreadRelatedComponents() {
messageListVC.updateJumpToUnreadMessageId(channelController.firstUnreadMessageId)
messageListVC.updateJumpToUnreadMessageId(
channelController.firstUnreadMessageId,
lastReadMessageId: channelController.lastReadMessageId
)
messageListVC.updateJumpToUnreadButtonVisibility()
}

Expand Down
64 changes: 55 additions & 9 deletions Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ open class ChatMessageListVC: _ViewController,
}

private var unreadSeparatorMessageId: MessageId?
private var lastReadMessageId: MessageId?
private var jumpToUnreadMessageId: MessageId?
private var jumpToUnreadMessageIndexPath: IndexPath? {
jumpToUnreadMessageId.flatMap(getIndexPath)
Expand Down Expand Up @@ -190,8 +191,8 @@ open class ChatMessageListVC: _ViewController,
listView.addGestureRecognizer(panGestureRecognizer)

scrollToBottomButton.addTarget(self, action: #selector(didTapScrollToBottomButton), for: .touchUpInside)
jumpToUnreadMessagesButton.addTarget(self, action: #selector(jumpToUnreadMessages))
jumpToUnreadMessagesButton.addDiscardButtonTarget(self, action: #selector(discardUnreadMessages))
jumpToUnreadMessagesButton.addTarget(self, action: #selector(didTapJumpToUnreadButton))
jumpToUnreadMessagesButton.addDiscardButtonTarget(self, action: #selector(didTapDiscardJumpToUnreadButton))
}

override open func setUpLayout() {
Expand Down Expand Up @@ -308,7 +309,8 @@ open class ChatMessageListVC: _ViewController,
guard isJumpToUnreadEnabled else { return }

if let unreadCount = dataSource?.channel(for: self)?.unreadCount,
unreadCount != jumpToUnreadMessagesButton.content {
unreadCount != jumpToUnreadMessagesButton.content,
unreadCount.messages > 0 {
jumpToUnreadMessagesButton.content = unreadCount
}

Expand Down Expand Up @@ -363,21 +365,21 @@ open class ChatMessageListVC: _ViewController,
listView.reloadRows(at: indexPathsToReload, with: .automatic)
}

func updateJumpToUnreadMessageId(_ jumpToUnreadMessageId: MessageId?) {
func updateJumpToUnreadMessageId(_ jumpToUnreadMessageId: MessageId?, lastReadMessageId: MessageId?) {
self.jumpToUnreadMessageId = jumpToUnreadMessageId
self.lastReadMessageId = lastReadMessageId
}

private func isMessageVisible(at indexPath: IndexPath) -> Bool {
guard let visibleIndexPaths = listView.indexPathsForVisibleRows else { return false }
return visibleIndexPaths.contains(indexPath)
}

@objc func jumpToUnreadMessages() {
guard let firstUnreadMessageId = unreadSeparatorMessageId else { return }
jumpToMessage(id: firstUnreadMessageId)
@objc func didTapJumpToUnreadButton() {
jumpToUnreadMessage()
}

@objc func discardUnreadMessages() {
@objc func didTapDiscardJumpToUnreadButton() {
delegate?.chatMessageListDidDiscardUnreadMessages(self)
}

Expand Down Expand Up @@ -609,13 +611,27 @@ open class ChatMessageListVC: _ViewController,
]
}

/// Jump to the current unread message if there is one.
/// - Parameter animated: `true` if you want to animate the change in position; `false` if it should be immediate.
/// - Parameter onHighlight: An optional closure to provide highlighting style when the message appears on screen.
open func jumpToUnreadMessage(animated: Bool = true, onHighlight: ((IndexPath) -> Void)? = nil) {
getCurrentUnreadMessageId { [weak self] messageId in
guard let jumpToUnreadMessageId = messageId else { return }

// The delay helps having a smoother scrolling animation.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.jumpToMessage(id: jumpToUnreadMessageId, animated: animated, onHighlight: onHighlight)
}
}
}

/// Jump to a given message.
/// In case the message is already loaded, it directly goes to it.
/// If not, it will load the messages around it and go to that page.
///
/// - Parameter id: The id of message which the message list should go to.
/// - Parameter onHighlight: An optional closure to provide highlighting style when the message appears on screen.
/// - Parameter animated: `true` if you want to animate the change in position; `false` if it should be immediate.
/// - Parameter onHighlight: An optional closure to provide highlighting style when the message appears on screen.
public func jumpToMessage(id: MessageId, animated: Bool = true, onHighlight: ((IndexPath) -> Void)? = nil) {
if let indexPath = getIndexPath(forMessageId: id) {
messagePendingScrolling = (id, animated)
Expand Down Expand Up @@ -693,6 +709,36 @@ open class ChatMessageListVC: _ViewController,
listView.reloadSkippedMessages()
}

/// Fetch the current unread message id.
///
/// If the message is available locally, we get it instantly.
/// If not, we need to fetch the page of messages where the `lastReadMessageId` is,
/// so that we can find the first unread message id next to it.
///
/// Note: This is a current backend limitation. Ideally, in the future,
/// we will get the `unreadMessageId` directly from the backend.
private func getCurrentUnreadMessageId(completion: @escaping (MessageId?) -> Void) {
if let jumpToUnreadMessageId = self.jumpToUnreadMessageId {
return completion(jumpToUnreadMessageId)
}

guard let lastReadMessageId = self.lastReadMessageId else {
return completion(nil)
}

delegate?.chatMessageListVC(self, shouldLoadPageAroundMessageId: lastReadMessageId) { error in
guard error == nil else {
return completion(nil)
}

guard let jumpToUnreadMessageId = self.jumpToUnreadMessageId else {
return completion(nil)
}

completion(jumpToUnreadMessageId)
}
}

// MARK: - UITableViewDataSource & UITableViewDelegate

open func numberOfSections(in tableView: UITableView) -> Int {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ open class JumpToUnreadMessagesButton: _Button, ThemeProvider {
}

open func addTarget(_ target: Any?, action: Selector) {
// TODO: https://github.com/GetStream/ios-issues-tracking/issues/529
// addTarget(target, action: action, for: .touchUpInside)
addTarget(target, action: action, for: .touchUpInside)
}

open func addDiscardButtonTarget(_ target: Any?, action: Selector) {
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChatUI/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ public struct Components {
/// Ex: When opening a channel from a push notification with a given message id.
public var shouldAnimateJumpToMessageWhenOpeningChannel: Bool = true

/// Whether it should jump to the unread message when the channel is initially opened.
/// By default it is disabled.
public var shouldJumpToUnreadWhenOpeningChannel: Bool = false

/// The view that shows the date for currently visible messages on top of message list.
public var messageListScrollOverlayView: ChatMessageListScrollOverlayView.Type =
ChatMessageListScrollOverlayView.self
Expand Down
12 changes: 9 additions & 3 deletions Sources/StreamChatUI/Utils/Throttler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,30 @@ import Foundation
/// Based on the implementation from Apple: https://developer.apple.com/documentation/combine/anypublisher/throttle(for:scheduler:latest:)
class Throttler {
private var workItem: DispatchWorkItem?
private let queue = DispatchQueue(label: "com.stream.throttler", qos: .background)
private let queue: DispatchQueue
private var previousRun: Date = Date.distantPast
let interval: TimeInterval
let broadcastLatestEvent: Bool

/// - Parameters:
/// - interval: The interval that an action can be executed.
/// - broadcastLatestEvent: A Boolean value that indicates whether we should be using the first or last event of the ones that are being throttled.
/// - queue: The queue where the work will be executed.
/// This last action will have a delay of the provided interval until it is executed.
init(interval: TimeInterval, broadcastLatestEvent: Bool = true) {
init(
interval: TimeInterval,
broadcastLatestEvent: Bool = true,
queue: DispatchQueue = .init(label: "com.stream.throttler", qos: .background)
) {
self.interval = interval
self.broadcastLatestEvent = broadcastLatestEvent
self.queue = queue
}

/// Throttle an action. It will cancel the previous action if exists, and it will execute the action immediately
/// if the last action executed was past the interval provided. If not, it will only be executed after a delay.
/// - Parameter action: The closure to be performed.
func throttle(_ action: @escaping () -> Void) {
func execute(_ action: @escaping () -> Void) {
workItem?.cancel()

let workItem = DispatchWorkItem { [weak self] in
Expand Down
1 change: 1 addition & 0 deletions Tests/StreamChatTests/StreamChatFlakyTests.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"MessageSender_Tests\/test_sender_doesNotRetainItself()",
"MessageSender_Tests\/test_sender_sendsMessage_withBothNotUploadableAttachmentAndUploadedAttachments()",
"MessageSender_Tests\/test_sender_sendsMessage_withUploadedAttachments()",
"MessageSender_Tests\/test_sender_sendsMessage_whenError_sendsEvent()",
"MessageUpdater_Tests\/test_flagMessage_happyPath()",
"MessageUpdater_Tests\/test_unpinMessage_propogatesMessageEditingError_ifLocalStateIsInvalidForUnpinning()",
"MessageUpdater_Tests\/test_deleteBouncedMessage_isDeletedLocally_whenLocalStateIsSendingFailed()",
Expand Down
1 change: 1 addition & 0 deletions Tests/StreamChatTests/StreamChatTestPlan.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"MessageSender_Tests\/test_sender_doesNotRetainItself()",
"MessageSender_Tests\/test_sender_sendsMessage_withBothNotUploadableAttachmentAndUploadedAttachments()",
"MessageSender_Tests\/test_sender_sendsMessage_withUploadedAttachments()",
"MessageSender_Tests\/test_sender_sendsMessage_whenError_sendsEvent()",
"MessageUpdater_Tests\/test_flagMessage_happyPath()",
"MessageUpdater_Tests\/test_unpinMessage_propogatesMessageEditingError_ifLocalStateIsInvalidForUnpinning()",
"MessageUpdater_Tests\/test_deleteBouncedMessage_isDeletedLocally_whenLocalStateIsSendingFailed()",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class ChatMessageListVCDelegate_Mock: ChatMessageListVCDelegate {

var shouldLoadPageAroundMessageCallCount = 0
var shouldLoadPageAroundMessageResult: Error?
func chatMessageListVC(_ vc: ChatMessageListVC, shouldLoadPageAroundMessage message: ChatMessage, _ completion: @escaping ((Error?) -> Void)) {

func chatMessageListVC(_ vc: ChatMessageListVC, shouldLoadPageAroundMessageId messageId: MessageId, _ completion: @escaping ((Error?) -> Void)) {
shouldLoadPageAroundMessageCallCount += 1
if let result = shouldLoadPageAroundMessageResult {
completion(result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1269,7 +1269,7 @@ class ThrottlerMock: Throttler {
super.init(interval: 0)
}

override func throttle(_ action: @escaping () -> Void) {
override func execute(_ action: @escaping () -> Void) {
action()
}
}
Loading

0 comments on commit 57d1b85

Please sign in to comment.