feat: Implement "scroll-back" to latest message when navigating replies

This commit adds a navigation history for quoted messages, such as if
Alice navigated to message A by pressing the "quoted message" inside the
reply message B and pressed the "scroll down button", the chat will be
scrolled back to message B instead of the very bottom. This is true for
as many replies as the user navigates through.
All the messages higher than the lowest fully visible message
are automatically cleared from the stack as the user scrolls.

This feature makes it easier to read threaded conversations,
especially in scenarios with much to catch up on. This is most useful in
group chats and communities.
This commit is contained in:
Arshak Aghakaryan 2023-07-28 21:13:04 +04:00
parent 5464d9c97a
commit fda7a4b482
2 changed files with 87 additions and 35 deletions

View File

@ -972,6 +972,8 @@ extension ConversationVC:
}
self.scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight)
let originalMessageTimestamp = Interaction.TimestampInfo(id: cellViewModel.id, timestampMs: cellViewModel.timestampMs)
self.replyNavigationStack.add(originalMessageTimestamp)
}
else if let linkPreview: LinkPreview = cellViewModel.linkPreview {
switch linkPreview.variant {

View File

@ -53,6 +53,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
var didFinishInitialLayout = false
var scrollDistanceToBottomBeforeUpdate: CGFloat?
var baselineKeyboardHeight: CGFloat = 0
var replyNavigationStack = ConversationReplyNavigationStack()
/// These flags are true between `viewDid/Will Appear/Disappear` and is used to prevent keyboard changes
/// from trying to animate (as the animations can cause buggy transitions)
@ -256,13 +257,17 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
image: UIImage(named: "ic_chevron_down")?
.withRenderingMode(.alwaysTemplate)
) { [weak self] in
// The table view's content size is calculated by the estimated height of cells,
// so the result may be inaccurate before all the cells are loaded. Use this
// to scroll to the last row instead.
self?.scrollToBottom(isAnimated: true)
if let previousPosition = self?.replyNavigationStack.removeLast() {
self?.scrollToInteractionIfNeeded(with: previousPosition, focusBehaviour: .highlight)
} else {
// The table view's content size is calculated by the estimated height of cells,
// so the result may be inaccurate before all the cells are loaded. Use this
// to scroll to the last row instead.
self?.scrollToBottom(isAnimated: true)
}
}
result.alpha = 0
return result
}()
@ -1640,6 +1645,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
isUserScrolling = false
if !decelerate {
self.onScrollFinished()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.onScrollFinished()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
@ -1669,6 +1681,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo)
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id, behaviour: behaviour)
}
self.onScrollFinished()
}
func onScrollFinished() {
DispatchQueue.main.async { [weak self] in
if let newestMessageViewModel: MessageViewModel = self?.getNewestMessageViewModel() {
self?.replyNavigationStack.removeTimestampsOlder(than: newestMessageViewModel.timestampMs)
}
}
}
func updateUnreadCountView(unreadCount: UInt?) {
@ -1935,36 +1957,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
}
func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) {
// We want to mark messages as read on load and while we scroll, so grab the newest message and mark
// everything older as read
//
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
// the table content appears above the input view
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
guard
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
let messagesSection: Int = visibleIndexPaths
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
.section,
let newestCellViewModel: MessageViewModel = visibleIndexPaths
.sorted()
.filter({ $0.section == messagesSection })
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
guard let cell: VisibleMessageCell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell else {
return nil
}
return (
view.convert(cell.frame, from: tableView),
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
)
})
// Exclude messages that are partially off the bottom of the screen
.filter({ $0.frame.maxY <= tableVisualBottom })
.last?
.cellViewModel
else {
// We want to mark messages as read on load and while we scroll,
// so grab the newest message and mark everything older as read
guard let newestCellViewModel = getNewestMessageViewModel() else {
// If we weren't able to get any visible cells for some reason then we should fall back to
// marking the provided interactionInfo as read just in case
if let interactionInfo: Interaction.TimestampInfo = interactionInfo {
@ -2001,9 +1996,64 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
}
}
func getNewestMessageViewModel() -> MessageViewModel? {
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
// the table content appears above the input view
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
let visibleIndexPaths: [IndexPath]? = self.tableView.indexPathsForVisibleRows
let messagesSection: Int? = visibleIndexPaths?.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?.section
let newestCellViewModel: MessageViewModel? = visibleIndexPaths?
.sorted()
.filter { $0.section == messagesSection }
.compactMap { indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
guard let cell: VisibleMessageCell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell else {
return nil
}
return (
view.convert(cell.frame, from: tableView),
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
)
}
// Exclude messages that are partially off the bottom of the screen
.filter { $0.frame.maxY <= tableVisualBottom }
.last?
.cellViewModel
return newestCellViewModel
}
// MARK: - SessionUtilRespondingViewController
func isConversation(in threadIds: [String]) -> Bool {
return threadIds.contains(self.viewModel.threadData.threadId)
}
}
struct ConversationReplyNavigationStack {
private var messageTimestamps: [Interaction.TimestampInfo] = []
mutating func add(_ timestamp: Interaction.TimestampInfo) {
if !messageTimestamps.contains(where: { $0.id == timestamp.id }) {
self.messageTimestamps.append(timestamp)
}
}
mutating func removeLast() -> Interaction.TimestampInfo? {
if isEmpty { return nil }
return messageTimestamps.removeLast()
}
var isEmpty: Bool { messageTimestamps.isEmpty }
mutating func removeTimestampsOlder(than timestamp: Int64) {
for i in (0 ..< self.messageTimestamps.count).reversed() {
if self.messageTimestamps[i].timestampMs <= timestamp {
self.messageTimestamps.remove(at: i)
}
}
}
}