diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 5f2930951f..a0d73713f2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -49,9 +49,9 @@ struct FramedItemView: View { if let qi = chatItem.quotedItem { ciQuoteView(qi) .onTapGesture { - if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { + if let itemId = qi.itemId { withAnimation { - scrollModel.scrollToItem(id: ci.id) + scrollModel.scrollToItem(id: itemId) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index f6969a451a..36c3bf57fd 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -23,6 +23,7 @@ struct ChatView: View { @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat @StateObject private var scrollModel = ReverseListScrollModel() + @State private var itemSection: Dictionary = Dictionary() @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -418,7 +419,7 @@ struct ChatView: View { let cInfo = chat.chatInfo let mergedItems = filtered(im.reversedChatItems) return GeometryReader { g in - ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in + ReverseList(items: mergedItems, scrollState: $scrollModel.state, itemSection: $itemSection ) { ci in let voiceNoFrame = voiceWithoutFrame(ci) let maxWidth = cInfo.chatType == .group ? voiceNoFrame @@ -440,6 +441,8 @@ struct ChatView: View { .id(ci.id) // Required to trigger `onAppear` on iOS15 } loadPage: { direction in loadChatItems(cInfo, direction) + } loadItemsAround: { chatItemId in + loadItemsAround(cInfo, chatItemId) } .opacity(ItemsModel.shared.isLoading ? 0 : 1) .padding(.vertical, -InvertedTableView.inset) @@ -870,6 +873,57 @@ struct ChatView: View { if reversedPage.count == 0 { firstPage = true } else { + reversedPage.forEach { ci in + itemSection[ci.id] = .bottom + } + im.reversedChatItems.append(contentsOf: reversedPage) + } + loadingItems = false + } + } catch let error { + logger.error("apiGetChat error: \(responseError(error))") + await MainActor.run { loadingItems = false } + } + } + } + + private func loadItemsAround(_ cInfo: ChatInfo, _ chatItemId: Int64) { + Task { + if loadingItems { return } + loadingItems = true + do { + var reversedPage = Array() + var chatItemsAvailable = true + // Load additional items until the page is +50 large after merging + while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { + let pagination: ChatPagination = .before(chatItemId: chatItemId, count: loadItemsPerPage / 2) + var chatItems = try await apiGetChatItems( + type: cInfo.chatType, + id: cInfo.apiId, + pagination: pagination, + search: searchText + ) + if let lastItem = chatItems.last { + let pagination: ChatPagination = .after(chatItemId: lastItem.id, count: loadItemsPerPage / 2) + let afterItems = try await apiGetChatItems( + type: cInfo.chatType, + id: cInfo.apiId, + pagination: pagination, + search: searchText + ) + chatItems.append(contentsOf: afterItems) + } + + chatItemsAvailable = !chatItems.isEmpty + reversedPage.append(contentsOf: chatItems.reversed()) + } + await MainActor.run { + if reversedPage.count == 0 { + firstPage = true + } else { + reversedPage.forEach { ci in + itemSection[ci.id] = .destination + } im.reversedChatItems.append(contentsOf: reversedPage) } loadingItems = false diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index b6cf500b84..9b1aef9952 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -16,17 +16,25 @@ enum ChatScrollDirection { case none } +enum ChatSection: CaseIterable { + case main + case destination + case bottom +} + /// A List, which displays it's items in reverse order - from bottom to top struct ReverseList: UIViewControllerRepresentable { let items: Array @Binding var scrollState: ReverseListScrollModel.State + @Binding var itemSection: Dictionary /// Closure, that returns user interface for a given item let content: (ChatItem) -> Content let loadPage: (ChatScrollDirection) -> Void - + let loadItemsAround: (Int64) -> Void + func makeUIViewController(context: Context) -> Controller { Controller(representer: self) } @@ -39,9 +47,10 @@ struct ReverseList: UIViewControllerRepresentable { case .nextPage: controller.scrollToNextPage() case let .item(id): - controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) + controller.update(items: items) + controller.scrollToItem(id: id) case .bottom: - controller.scroll(to: 0, position: .top) + controller.scroll(to: 0, position: .top, section: .bottom) } } else { controller.update(items: items) @@ -50,9 +59,8 @@ struct ReverseList: UIViewControllerRepresentable { /// Controller, which hosts SwiftUI cells class Controller: UITableViewController { - private enum Section { case main } var representer: ReverseList - private var dataSource: UITableViewDiffableDataSource! + private var dataSource: UITableViewDiffableDataSource! private var itemCount: Int = 0 private let updateFloatingButtons = PassthroughSubject() private var bag = Set() @@ -79,12 +87,12 @@ struct ReverseList: UIViewControllerRepresentable { forCellReuseIdentifier: cellReuseId ) } - + // 3. Configure data source - self.dataSource = UITableViewDiffableDataSource( + self.dataSource = UITableViewDiffableDataSource( tableView: tableView ) { (tableView, indexPath, item) -> UITableViewCell? in - if indexPath.item > self.itemCount - 8, self.itemCount > 8 { + if self.representer.scrollState == .atDestination, indexPath.item > self.itemCount - 8, self.itemCount > 8 { self.representer.loadPage(.toOldest) } let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) @@ -167,17 +175,32 @@ struct ReverseList: UIViewControllerRepresentable { ) Task { representer.scrollState = .atDestination } } + + /// Scrolls up + func scrollToItem(id: Int64) { + if let loadedIndex = representer.items.firstIndex(where: { $0.id == id }) { + let ci = representer.items[loadedIndex] + if let indexPath = dataSource.indexPath(for: ci), + let section = dataSource.sectionIdentifier(for: indexPath.section) { + self.scroll(to: indexPath.row, position: .bottom, section: section) + } + } else { + Task { + self.representer.loadItemsAround(id) + } + } + } /// Scrolls to Item at index path /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil` - func scroll(to index: Int?, position: UITableView.ScrollPosition) { + func scroll(to index: Int?, position: UITableView.ScrollPosition, section: ChatSection) { var animated = false if #available(iOS 16.0, *) { animated = true } - if let index, tableView.numberOfRows(inSection: 0) != 0 { + if let index, let sectionIndex = dataSource.index(for: section), tableView.numberOfRows(inSection: sectionIndex) != 0 { tableView.scrollToRow( - at: IndexPath(row: index, section: 0), + at: IndexPath(row: index, section: sectionIndex), at: position, animated: animated ) @@ -191,9 +214,29 @@ struct ReverseList: UIViewControllerRepresentable { } func update(items: [ChatItem]) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(items) + var snapshot = NSDiffableDataSourceSnapshot() + var itemsBySection: Dictionary = Dictionary() + items.forEach { ci in + var section: ChatSection = .bottom + if let sec = representer.itemSection[ci.id] { + section = sec + } + + if var existingItems = itemsBySection[section] { + existingItems.append(ci) + itemsBySection[section] = existingItems + } else { + itemsBySection[section] = [ci] + } + } + snapshot.appendSections([.bottom, .destination]) + if let bottomItems = itemsBySection[.bottom] { + snapshot.appendItems(bottomItems, toSection: .bottom) + } + if let destinationItems = itemsBySection[.destination] { + snapshot.appendItems(destinationItems, toSection: .destination) + } + dataSource.defaultRowAnimation = .none dataSource.apply( snapshot,