diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 6da893d1d2..5b23287d42 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -62,6 +62,10 @@ struct FramedItemView: View { withAnimation { scrollModel.scrollToItem(id: ci.id) } + } else if let id = qi.itemId { + scrollModel.scrollToItem(id: id) + } else { + showQuotedItemDoesNotExistAlert() } } } else if let itemForwarded = chatItem.meta.itemForwarded { @@ -336,6 +340,13 @@ struct FramedItemView: View { return videoWidth } } + + private func showQuotedItemDoesNotExistAlert() { + AlertManager.shared.showAlertMsg( + title: "No message", + message: "This message was deleted or not received yet." + ) + } } @ViewBuilder func toggleSecrets(_ ft: [FormattedText]?, _ showSecrets: Binding, _ v: V) -> some View { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 046ab6c208..61f5d45043 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -105,7 +105,7 @@ func apiLoadMessages( } case .around: newItems.append(contentsOf: oldItems) - let newSplits = removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) // currently, items will always be added on top, which is index 0 newItems.insert(contentsOf: chat.chatItems, at: 0) let newReversed: [ChatItem] = newItems.reversed() @@ -270,15 +270,15 @@ private func removeDuplicatesAndUpperSplits( _ newItems: inout [ChatItem], _ chat: Chat, _ splits: [Int64], - _ visibleItemIndexesNonReversed: () -> ClosedRange -) -> [Int64] { + _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange +) async -> [Int64] { if splits.isEmpty { removeDuplicates(&newItems, chat) return splits } var newSplits = splits - let visibleItemIndexes = visibleItemIndexesNonReversed() + let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } let (newIds, _) = mapItemsToIds(chat.chatItems) var idsToTrim: [BoxedValue>] = [] idsToTrim.append(BoxedValue(Set())) diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index dc909b35b4..00a7879cf8 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -85,6 +85,22 @@ func preloadItems(_ mergedItems: MergedItems, _ allowLoadMoreItems: Bool, _ list } } +func oldestPartiallyVisibleListItemInListStateOrNull(_ mergedItems: MergedItems, _ listState: ListState) -> ListItem? { + if listState.lastVisibleItemIndex < mergedItems.items.count { + return mergedItems.items[listState.lastVisibleItemIndex].oldest() + } else { + return mergedItems.items.last?.oldest() + } +} + +private func lastFullyVisibleIemInListState(_ mergedItems: MergedItems, _ listState: ListState) -> ChatItem? { + if listState.lastVisibleItemIndex < mergedItems.items.count { + return mergedItems.items[listState.lastVisibleItemIndex].newest().item + } else { + return mergedItems.items.last?.newest().item + } +} + private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVisibleIndex: Int, _ remaining: Int, _ splits: [SplitRange]) -> Int? { for split in splits { // before any split @@ -105,3 +121,16 @@ private func findLastIndexToLoadFromInSplits(_ firstVisibleIndex: Int, _ lastVis return nil } +func tryBlockAndSetLoadingMore(_ loadingMoreItems: Binding, _ block: @escaping () async throws -> Void) async { + do { + await MainActor.run { + loadingMoreItems.wrappedValue = true + } + try await block() + } catch { + logger.error("Error loading more items: \(error)") + } + await MainActor.run { + loadingMoreItems.wrappedValue = false + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9ead14ab6c..8e43de7914 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -32,7 +32,7 @@ struct ChatView: View { @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? - @State private var loadingItems = false + @State private var loadingMoreItems = false @State private var searchMode = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -78,7 +78,7 @@ struct ChatView: View { VStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { chatItemsList() - FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) + FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat, loadingMoreItems: $loadingMoreItems) } connectingText() if selectedChatItems == nil { @@ -177,6 +177,7 @@ struct ChatView: View { showChatInfoSheet = false selectedChatItems = nil revealedItems = Set() + // LALAL scrollModel.scrollToBottom() stopAudioPlayer() if let cId { @@ -444,7 +445,7 @@ struct ChatView: View { return GeometryReader { g in let _ = logger.debug("LALAL RELOAD \(im.reversedChatItems.count)") // LALAL CAN I CHANGE BINDING LIKE THIS IN ignoreLoadingRequests? - ReverseList(items: im.reversedChatItems, mergedItems: $mergedItems, revealedItems: $revealedItems, unreadCount: Binding.constant(chat.chatStats.unreadCount), scrollState: $scrollModel.state, allowLoadMoreItems: $allowLoadMoreItems, ignoreLoadingRequests: searchValueIsEmpty ? $ignoreLoadingRequests : Binding.constant([])) { index, mergedItem in + ReverseList(items: im.reversedChatItems, mergedItems: $mergedItems, revealedItems: $revealedItems, unreadCount: Binding.constant(chat.chatStats.unreadCount), scrollState: $scrollModel.state, loadingMoreItems: $loadingMoreItems, allowLoadMoreItems: $allowLoadMoreItems, ignoreLoadingRequests: searchValueIsEmpty ? $ignoreLoadingRequests : Binding.constant([])) { index, mergedItem in let ci = switch mergedItem { case let .single(item, _): item.item case let .grouped(items, _, _, _, _, _, _): items.boxedValue.last!.item @@ -477,12 +478,16 @@ struct ChatView: View { ) .environmentObject(theme) // crashes without this line when scrolling to the first unread in ReverseList .id(ci.id) // Required to trigger `onAppear` on iOS15 - } loadItems: { pagination, visibleItemIndexesNonReversed in - await loadChatItems(cInfo, pagination, visibleItemIndexesNonReversed) + } loadItems: { unchecked, pagination, visibleItemIndexesNonReversed in + if unchecked { + await loadChatItemsUnchecked(cInfo, pagination, visibleItemIndexesNonReversed) + } else { + await loadChatItems(cInfo, pagination, visibleItemIndexesNonReversed) + } } .onAppear { mergedItems = MergedItems.create(im.reversedChatItems, chat.chatStats.unreadCount, revealedItems, ItemsModel.shared.chatState) - loadLastItems($loadingItems, chat.chatInfo) + loadLastItems($loadingMoreItems, chat.chatInfo) allowLoadMoreItems = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true @@ -498,7 +503,7 @@ struct ChatView: View { mergedItems = MergedItems.create(im.reversedChatItems, unreadCount, revealedItems, ItemsModel.shared.chatState) } .onChange(of: chat.id) { _ in - loadLastItems($loadingItems, chat.chatInfo) + loadLastItems($loadingMoreItems, chat.chatInfo) allowLoadMoreItems = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { allowLoadMoreItems = true @@ -541,22 +546,29 @@ struct ChatView: View { @Published var isNearBottom: Bool = true @Published var date: Date? @Published var isDateVisible: Bool = false + var mergedItems: MergedItems = MergedItems.init(items: [], splits: [], indexInParentItems: [:]) var totalUnread: Int = 0 var isReallyNearBottom: Bool = true var hideDateWorkItem: DispatchWorkItem? - func updateOnListChange(_ listState: ListState) { + func updateOnListChange(_ mergedItems: MergedItems, _ listState: ListState) { let im = ItemsModel.shared - let unreadBelow = - if let id = listState.bottomItemId, - let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) - { - im.reversedChatItems[.. 0 && listState.scrollOffset < 500 + it.mergedItems = mergedItems } // set floating button indication mode @@ -617,6 +630,7 @@ struct ChatView: View { let theme: AppTheme let scrollModel: ReverseListScrollModel let chat: Chat + @Binding var loadingMoreItems: Bool @ObservedObject var model = FloatingButtonModel.shared var body: some View { @@ -638,7 +652,12 @@ struct ChatView: View { .foregroundColor(theme.colors.primary) } .onTapGesture { - scrollModel.scrollToNextPage() + if let index = model.mergedItems.items.lastIndex(where: { $0.hasUnread() }) { + // scroll to the top unread item + loadingMoreItems = true + scrollModel.scrollToRow(row: index) + loadingMoreItems = false + } } .contextMenu { Button { @@ -904,8 +923,8 @@ struct ChatView: View { } private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination, _ visibleItemIndexesNonReversed: @escaping () -> ClosedRange = { 0 ... 0 }) async -> Bool { - if loadingItems { return false } - loadingItems = true + if loadingMoreItems { return false } + loadingMoreItems = true var chatItemsAvailable = true var itemsCountChanged = false // Load additional items until the page is +50 large after merging @@ -923,12 +942,34 @@ struct ChatView: View { chatItemsAvailable = itemsCountChanged //} logger.debug("LALAL ITEMCOUNTCHANGED \(itemsCountChanged) \(oldCount) \(im.reversedChatItems.count)") + await MainActor.run { - loadingItems = false + loadingMoreItems = false } return true } + private func loadChatItemsUnchecked(_ cInfo: ChatInfo, _ pagination: ChatPagination, _ visibleItemIndexesNonReversed: @escaping () -> ClosedRange = { 0 ... 0 }) async -> Bool { + var chatItemsAvailable = true + var itemsCountChanged = false + // Load additional items until the page is +50 large after merging + //while chatItemsAvailable && filtered(im.reversedChatItems).count < loadItemsPerPage { + let oldCount = im.reversedChatItems.count + await apiLoadMessages( + cInfo.chatType, + cInfo.apiId, + pagination, + im.chatState, + searchText, + visibleItemIndexesNonReversed + ) + itemsCountChanged = im.reversedChatItems.count != oldCount + chatItemsAvailable = itemsCountChanged + //} + logger.debug("LALAL ITEMCOUNTCHANGED \(itemsCountChanged) \(oldCount) \(im.reversedChatItems.count)") + return true + } + func stopAudioPlayer() { VoiceItemState.chatView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.chatView = [:] diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index a79b28ba4b..ebc5324e53 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -18,6 +18,7 @@ struct ReverseList: UIViewControllerRepresentable { @Binding var unreadCount: Int @Binding var scrollState: ReverseListScrollModel.State + @Binding var loadingMoreItems: Bool @Binding var allowLoadMoreItems: Bool @Binding var ignoreLoadingRequests: [Int64] @@ -25,8 +26,8 @@ struct ReverseList: UIViewControllerRepresentable { /// Index, merged item let content: (Int, MergedItem) -> Content - // pagination, visibleItemIndexesNonReversed - let loadItems: (ChatPagination, @escaping @MainActor () -> ClosedRange) async -> Bool + // unchecked, pagination, visibleItemIndexesNonReversed + let loadItems: (Bool, ChatPagination, @escaping @MainActor () -> ClosedRange) async -> Bool func makeUIViewController(context: Context) -> Controller { Controller(representer: self) @@ -40,7 +41,14 @@ struct ReverseList: UIViewControllerRepresentable { case .nextPage: controller.scrollToNextPage() case let .item(id): - controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) + let row = items.firstIndex(where: { $0.id == id }) + if let row { + controller.scroll(to: row, position: .bottom) + } else { + controller.scrollToItem(id) + } + case let .row(row): + controller.scroll(to: row, position: .bottom) case .bottom: controller.scroll(to: 0, position: .top) } @@ -111,30 +119,35 @@ struct ReverseList: UIViewControllerRepresentable { tableView: tableView ) { (tableView, indexPath, item) -> UITableViewCell? in //if indexPath.item > self.itemCount - 8 { - logger.debug("LALAL ITEM \(indexPath.item)") - Task { - // do not put it outside Task because it will be stackoverflow error - let state = await MainActor.run { - self.getListState() + logger.debug("LALAL ITEM \(indexPath.item)") + Task { + // do not put it outside Task because it will be stackoverflow error + let state = await MainActor.run { + self.getListState() + } + if let state, prevFirstVisible != state.firstVisibleItemIndex { + logger.debug("LALAL LOADING before") + if representer.scrollState != .atDestination { + // do not try to load anything in order to not interupt scrolling + return } - if let state, prevFirstVisible != state.firstVisibleItemIndex { - prevFirstVisible = state.firstVisibleItemIndex - logger.debug("LALAL LOADING before") - await preloadItems(self.representer.mergedItems, self.representer.allowLoadMoreItems, state, self.representer.$ignoreLoadingRequests) { pagination in - logger.debug("LALAL LOADING INSIDE") - let triedToLoad = await self.representer.loadItems(pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems)) }) - if triedToLoad { - let t = self.tableView.panGestureRecognizer.translation(in: self.tableView.superview!) - if t.y != 0 { - self.translationToApply = t - } - // stop scrolling - self.tableView.setContentOffset(self.tableView.contentOffset, animated: false) + prevFirstVisible = state.firstVisibleItemIndex + await preloadItems(self.representer.mergedItems, self.representer.allowLoadMoreItems, state, self.representer.$ignoreLoadingRequests) { pagination in + logger.debug("LALAL LOADING INSIDE") + let triedToLoad = await self.representer.loadItems(false, pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems)) }) + let superview = self.tableView.superview + if triedToLoad, let superview { + let t = self.tableView.panGestureRecognizer.translation(in: superview) + if t.y != 0 { + self.translationToApply = t } - return triedToLoad + // stop scrolling + self.tableView.setContentOffset(self.tableView.contentOffset, animated: false) } + return triedToLoad } } + } //} let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) if #available(iOS 16.0, *) { @@ -167,7 +180,7 @@ struct ReverseList: UIViewControllerRepresentable { .throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true) .sink { if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) { - ChatView.FloatingButtonModel.shared.updateOnListChange(listState) + ChatView.FloatingButtonModel.shared.updateOnListChange(representer.mergedItems, listState) } } .store(in: &bag) @@ -328,10 +341,10 @@ struct ReverseList: UIViewControllerRepresentable { } } else if wasCount != snapshot.numberOfItems { logger.debug("LALAL drag \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)") -// tableView.panGestureRecognizer.isEnabled = false -// logger.debug("LALAL drag2 \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)") + // tableView.panGestureRecognizer.isEnabled = false + // logger.debug("LALAL drag2 \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)") if tableView.isDecelerating { -// CATransaction.begin() + // CATransaction.begin() itemsInPrevSnapshot = itemsInCurrentSnapshot tableView.beginUpdates() dataSource.apply( @@ -339,7 +352,7 @@ struct ReverseList: UIViewControllerRepresentable { animatingDifferences: false ) tableView.endUpdates() -// CATransaction.commit() + // CATransaction.commit() tableView.panGestureRecognizer.isEnabled = true } else { itemsInPrevSnapshot = itemsInCurrentSnapshot @@ -348,30 +361,30 @@ struct ReverseList: UIViewControllerRepresentable { animatingDifferences: false ) //if snapshot.itemIdentifiers[0] == prevSnapshot.itemIdentifiers[0] { - // added new items to top + // added new items to top //} else { - // added new items to bottom -// logger.debug("LALAL WAS HEIGHT \(wasContentHeight) now \(self.tableView.contentSize.height), offset was \(wasOffset), now \(self.tableView.contentOffset.y), will be \(self.tableView.contentOffset.y + (self.tableView.contentSize.height - wasContentHeight)), countDiff \(countDiff), wasVisibleRow \(wasFirstVisibleRow), wasFirstVisibleOffset \(wasFirstVisibleOffset)") - self.tableView.scrollToRow( - at: IndexPath(row: max(0, min(snapshot.numberOfItems - 1, countDiff + wasFirstVisibleRow)), section: 0), - at: .top, - animated: false - ) - self.tableView.setContentOffset( - CGPoint(x: 0, y: self.tableView.contentOffset.y - wasFirstVisibleOffset), - animated: false - ) - let state = getListState()! - logger.debug("LALAL NOW FIRST VISIBLE \(state.firstVisibleItemIndex) \(state.firstVisibleItemOffset)") + // added new items to bottom + // logger.debug("LALAL WAS HEIGHT \(wasContentHeight) now \(self.tableView.contentSize.height), offset was \(wasOffset), now \(self.tableView.contentOffset.y), will be \(self.tableView.contentOffset.y + (self.tableView.contentSize.height - wasContentHeight)), countDiff \(countDiff), wasVisibleRow \(wasFirstVisibleRow), wasFirstVisibleOffset \(wasFirstVisibleOffset)") + self.tableView.scrollToRow( + at: IndexPath(row: max(0, min(snapshot.numberOfItems - 1, countDiff + wasFirstVisibleRow)), section: 0), + at: .top, + animated: false + ) + self.tableView.setContentOffset( + CGPoint(x: 0, y: self.tableView.contentOffset.y - wasFirstVisibleOffset), + animated: false + ) + let state = getListState()! + logger.debug("LALAL NOW FIRST VISIBLE \(state.firstVisibleItemIndex) \(state.firstVisibleItemOffset)") if let t = translationToApply { -// self.tableView.panGestureRecognizer.setTranslation(CGPointMake(t.x, t.y), in: self.tableView.superview!) + // self.tableView.panGestureRecognizer.setTranslation(CGPointMake(t.x, t.y), in: self.tableView.superview!) let o = self.tableView.contentOffset -// UIView.animate(withDuration: 0.5) { -// self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: false) -// self.tableView.layoutIfNeeded() -// } + // UIView.animate(withDuration: 0.5) { + // self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: false) + // self.tableView.layoutIfNeeded() + // } self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: true) translationToApply = nil } @@ -392,22 +405,23 @@ struct ReverseList: UIViewControllerRepresentable { visibleRows.last?.row ?? 0 < representer.mergedItems.items.count { let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset let topItemDate: Date? = - if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { - representer.mergedItems.items[lastVisible.item].oldest().item.meta.itemTs - } else { - nil - } + if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { + representer.mergedItems.items[lastVisible.item].oldest().item.meta.itemTs + } else { + nil + } let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) let firstVisibleOffset: CGFloat? = if let row = firstVisible?.row { offsetForRow(row) } else { nil } let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) + logger.debug("LALAL LAST \(lastVisible?.item ?? -2)") let bottomItemId: ChatItem.ID? = - if let firstVisible { - representer.mergedItems.items[firstVisible.row].newest().item.id - } else { - nil - } + if let firstVisible { + representer.mergedItems.items[firstVisible.row].newest().item.id + } else { + nil + } return ListState(scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId, firstVisibleItemIndex: firstVisible?.row ?? 0, lastVisibleItemIndex: lastVisible?.row ?? 0, firstVisibleItemOffset: firstVisibleOffset ?? 0) } return nil @@ -435,6 +449,46 @@ struct ReverseList: UIViewControllerRepresentable { relativeFrame.minY < tableView.frame.height - InvertedTableView.inset } else { false } } + + func scrollToItem(_ itemId: Int64) { + Task { + logger.debug("LALAL SCROLL TO ITEM \(itemId) \(ItemsModel.shared.reversedChatItems.count)") + do { + var index = representer.mergedItems.indexInParentItems[itemId] + // setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to + // it will trigger loading more items and will scroll to incorrect position (because of trimming) + await MainActor.run { + representer.loadingMoreItems = true + } + logger.debug("LALAL SCROLL TO ITEM \(index ?? -2)") + if index == nil { + let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2) + let oldSize = ItemsModel.shared.reversedChatItems.count + _ = await self.representer.loadItems(true, pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems))}) + var repeatsLeft = 50 + while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 { + try await Task.sleep(nanoseconds: 20_000000) + repeatsLeft -= 1 + logger.debug("LALAL SCROLL REPEATS \(repeatsLeft)") + } + index = representer.mergedItems.indexInParentItems[itemId] + } + logger.debug("LALAL SCROLL TO ITEM2 \(index ?? -3) \(ItemsModel.shared.reversedChatItems.count)") + if let index { + scroll(to: min(ItemsModel.shared.reversedChatItems.count - 1, index), position: .bottom) + } else { + await MainActor.run { + representer.scrollState = .atDestination + } + } + } catch { + logger.error("Error scrolling to item: \(error)") + } + await MainActor.run { + representer.loadingMoreItems = false + } + } + } } /// `UIHostingConfiguration` back-port for iOS14 and iOS15 @@ -502,6 +556,7 @@ class ReverseListScrollModel: ObservableObject { enum Destination: Equatable { case nextPage case item(ChatItem.ID) + case row(Int) case bottom } @@ -522,6 +577,10 @@ class ReverseListScrollModel: ObservableObject { func scrollToItem(id: ChatItem.ID) { state = .scrollingTo(.item(id)) } + + func scrollToRow(row: Int) { + state = .scrollingTo(.row(row)) + } } fileprivate let cellReuseId = "hostingCell"