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:
parent
5464d9c97a
commit
fda7a4b482
|
@ -972,6 +972,8 @@ extension ConversationVC:
|
||||||
}
|
}
|
||||||
|
|
||||||
self.scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight)
|
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 {
|
else if let linkPreview: LinkPreview = cellViewModel.linkPreview {
|
||||||
switch linkPreview.variant {
|
switch linkPreview.variant {
|
||||||
|
|
|
@ -53,6 +53,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
var didFinishInitialLayout = false
|
var didFinishInitialLayout = false
|
||||||
var scrollDistanceToBottomBeforeUpdate: CGFloat?
|
var scrollDistanceToBottomBeforeUpdate: CGFloat?
|
||||||
var baselineKeyboardHeight: CGFloat = 0
|
var baselineKeyboardHeight: CGFloat = 0
|
||||||
|
var replyNavigationStack = ConversationReplyNavigationStack()
|
||||||
|
|
||||||
/// These flags are true between `viewDid/Will Appear/Disappear` and is used to prevent keyboard changes
|
/// 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)
|
/// 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")?
|
image: UIImage(named: "ic_chevron_down")?
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
) { [weak self] in
|
) { [weak self] in
|
||||||
// The table view's content size is calculated by the estimated height of cells,
|
if let previousPosition = self?.replyNavigationStack.removeLast() {
|
||||||
// so the result may be inaccurate before all the cells are loaded. Use this
|
self?.scrollToInteractionIfNeeded(with: previousPosition, focusBehaviour: .highlight)
|
||||||
// to scroll to the last row instead.
|
} else {
|
||||||
self?.scrollToBottom(isAnimated: true)
|
// 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
|
result.alpha = 0
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -1640,6 +1645,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
|
|
||||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||||
isUserScrolling = false
|
isUserScrolling = false
|
||||||
|
if !decelerate {
|
||||||
|
self.onScrollFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
self.onScrollFinished()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
@ -1669,6 +1681,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo)
|
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo)
|
||||||
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id, behaviour: behaviour)
|
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?) {
|
func updateUnreadCountView(unreadCount: UInt?) {
|
||||||
|
@ -1935,36 +1957,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
||||||
}
|
}
|
||||||
|
|
||||||
func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) {
|
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
|
// We want to mark messages as read on load and while we scroll,
|
||||||
// everything older as read
|
// so grab the newest message and mark everything older as read
|
||||||
//
|
guard let newestCellViewModel = getNewestMessageViewModel() else {
|
||||||
// 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 {
|
|
||||||
// If we weren't able to get any visible cells for some reason then we should fall back to
|
// 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
|
// marking the provided interactionInfo as read just in case
|
||||||
if let interactionInfo: Interaction.TimestampInfo = interactionInfo {
|
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
|
// MARK: - SessionUtilRespondingViewController
|
||||||
|
|
||||||
func isConversation(in threadIds: [String]) -> Bool {
|
func isConversation(in threadIds: [String]) -> Bool {
|
||||||
return threadIds.contains(self.viewModel.threadData.threadId)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue