From fefddb3b5a6240df853a2bdecf8b767bfbc4ad91 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:45:24 +0700 Subject: [PATCH] ios: go to forwarded item or search result (#5679) * ios: go to forwarded item or search result * react on touch * changes --- apps/ios/Shared/Model/ChatModel.swift | 17 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 24 ++- .../Shared/Views/Chat/ChatScrollHelpers.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 182 +++++++++++++----- .../Chat/ComposeMessage/ComposeView.swift | 4 + .../ComposeMessage/NativeTextEditor.swift | 9 +- .../Chat/ComposeMessage/SendMessageView.swift | 8 +- .../Shared/Views/Chat/EndlessScrollView.swift | 24 ++- apps/ios/Shared/Views/TerminalView.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 14 ++ 11 files changed, 228 insertions(+), 64 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 9171bf5073..a1c5a55c3b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -97,6 +97,22 @@ class ItemsModel: ObservableObject { } } } + + func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + loadChatTask = Task { + // try? await Task.sleep(nanoseconds: 1000_000000) + await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) + if !Task.isCancelled { + await MainActor.run { + if openAroundItemId == nil { + ChatModel.shared.chatId = chatId + } + } + } + } + } } class ChatTagsModel: ObservableObject { @@ -259,6 +275,7 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? + @Published var openAroundItemId: ChatItem.ID? = nil var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0ef2a87aa7..354f8243fc 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -338,7 +338,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async { +func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { let m = ChatModel.shared let im = ItemsModel.shared m.chatItemStatuses = [:] @@ -348,7 +348,7 @@ func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) asyn ItemsModel.shared.chatItemsChangesListener.cleared() } } - await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 }) + await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 }) } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 4253a4f1e4..add28cd7f9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -16,6 +16,7 @@ func apiLoadMessages( _ pagination: ChatPagination, _ chatState: ActiveChatState, _ search: String = "", + _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } ) async { let chat: Chat @@ -32,7 +33,8 @@ func apiLoadMessages( // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled { + // When openAroundItemId is provided, chatId can be different too + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled { return } @@ -102,8 +104,13 @@ func apiLoadMessages( } } case .around: - newItems.append(contentsOf: oldItems) - let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + let newSplits: [Int64] + if openAroundItemId == nil { + newItems.append(contentsOf: oldItems) + newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + } else { + newSplits = [] + } // currently, items will always be added on top, which is index 0 newItems.insert(contentsOf: chat.chatItems, at: 0) let newReversed: [ChatItem] = newItems.reversed() @@ -114,8 +121,15 @@ func apiLoadMessages( chatState.totalAfter = navInfo.afterTotal chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadAfter = navInfo.afterUnread - // no need to set it, count will be wrong - // unreadAfterNewestLoaded.value = navInfo.afterUnread + + if let openAroundItemId { + chatState.unreadAfterNewestLoaded = navInfo.afterUnread + ChatModel.shared.openAroundItemId = openAroundItemId + ChatModel.shared.chatId = chatId + } else { + // no need to set it, count will be wrong + // chatState.unreadAfterNewestLoaded = navInfo.afterUnread + } } case .last: newItems.append(contentsOf: oldItems) diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index 094c6e13e1..873f24d5c3 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -46,7 +46,7 @@ func preloadIfNeeded( loadItems: @escaping (Bool, ChatPagination) async -> Bool ) { let state = PreloadState.shared - guard !listState.isScrolling, + guard !listState.isScrolling && !listState.isAnimatedScrolling, state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count, !state.preloading, listState.totalItemsCount > 0 diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index f3bfa294b4..90c9629352 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -29,6 +29,7 @@ struct ChatView: View { @State private var composeState = ComposeState() @State private var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? @@ -37,7 +38,7 @@ struct ChatView: View { @State private var requestedTopScroll = false @State private var loadingBottomItems = false @State private var requestedBottomScroll = false - @State private var searchMode = false + @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @@ -54,11 +55,8 @@ struct ChatView: View { @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false - @State private var updateMergedItemsTask: Task? = nil @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() - private let useItemsUpdateTask = false - @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -101,6 +99,7 @@ struct ChatView: View { chat: chat, composeState: $composeState, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, selectedRange: $selectedRange ) .disabled(!cInfo.sendMsgEnabled) @@ -131,7 +130,7 @@ struct ChatView: View { } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { - if searchMode { searchToolbar() } + if showSearch { searchToolbar() } Divider() } .background(ToolbarMaterial.material(toolbarMaterial)) @@ -232,15 +231,45 @@ struct ChatView: View { scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() - if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + + if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { scrollView.scrollToItem(unreadIndex) } else { scrollView.scrollToBottom() } + if chatModel.openAroundItemId != nil { + chatModel.openAroundItemId = nil + } } else { dismiss() } } + .onChange(of: chatModel.openAroundItemId) { openAround in + if let openAround { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + chatModel.openAroundItemId = nil + + if let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } + + // this may already being loading because of changed chat id (see .onChange(of: chat.id) + if !loadingBottomItems { + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + allowLoadMoreItems = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + } + } .onDisappear { VideoPlayerView.players.removeAll() stopAudioPlayer() @@ -429,9 +458,13 @@ struct ChatView: View { index = mergedItems.boxedValue.indexInParentItems[itemId] } if let index { - await MainActor.run { animatedScrollingInProgress = true } - await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) - await MainActor.run { animatedScrollingInProgress = false } + closeKeyboardAndRun { + Task { + await MainActor.run { animatedScrollingInProgress = true } + await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) + await MainActor.run { animatedScrollingInProgress = false } + } + } } } catch { logger.error("Error scrolling to item: \(error)") @@ -460,9 +493,7 @@ struct ChatView: View { .cornerRadius(10.0) Button ("Cancel") { - searchText = "" - searchMode = false - searchFocussed = false + closeSearch() Task { await loadChat(chat: chat) } } } @@ -517,7 +548,9 @@ struct ChatView: View { showChatInfoSheet: $showChatInfoSheet, revealedItems: $revealedItems, selectedChatItems: $selectedChatItems, - forwardedChatItems: $forwardedChatItems + forwardedChatItems: $forwardedChatItems, + searchText: $searchText, + closeKeyboardAndRun: closeKeyboardAndRun ) // crashes on Cell size calculation without this line .environmentObject(ChatModel.shared) @@ -535,25 +568,10 @@ struct ChatView: View { } } .onChange(of: im.reversedChatItems) { items in - updateMergedItemsTask?.cancel() - if useItemsUpdateTask { - updateMergedItemsTask = Task { - let items = MergedItems.create(items, revealedItems, im.chatState) - if Task.isCancelled { - return - } - await MainActor.run { - mergedItems.boxedValue = items - scrollView.updateItems(mergedItems.boxedValue.items) - } - } - } else { - mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) - scrollView.updateItems(mergedItems.boxedValue.items) - } + mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: revealedItems) { revealed in - updateMergedItemsTask?.cancel() mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) scrollView.updateItems(mergedItems.boxedValue.items) } @@ -567,6 +585,7 @@ struct ChatView: View { .padding(.vertical, -100) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { s in + guard showSearch else { return } Task { await loadChat(chat: chat, search: s) mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) @@ -880,11 +899,29 @@ struct ChatView: View { } private func focusSearch() { - searchMode = true + showSearch = true searchFocussed = true searchText = "" } + private func closeSearch() { + showSearch = false + searchText = "" + searchFocussed = false + } + + private func closeKeyboardAndRun(_ action: @escaping () -> Void) { + var delay: TimeInterval = 0 + if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch { + delay = 0.5 + closeSearch() + hideKeyboard() + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + action() + } + } + private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { @@ -1079,6 +1116,7 @@ struct ChatView: View { pagination, im.chatState, searchText, + nil, { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } ) return true @@ -1136,10 +1174,14 @@ struct ChatView: View { @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var msgWidth: CGFloat = 0 + @State private var touchInProgress: Bool = false @Binding var selectedChatItems: Set? @Binding var forwardedChatItems: [ChatItem] + @Binding var searchText: String + var closeKeyboardAndRun: (@escaping () -> Void) -> Void + @State private var allowMenu: Bool = true @State private var markedRead = false @State private var markReadTask: Task? = nil @@ -1257,6 +1299,16 @@ struct ChatView: View { markedRead = false } .actionSheet(item: $actionSheet) { $0.actionSheet } + // skip updating struct on touch if no need to show GoTo button + .if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) { + // long press listener steals taps from top-level listener, so repeating it's logic here as well + $0.onTapGesture { + hideKeyboard() + } + .onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in + touchInProgress = pressing + }) + } } private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { @@ -1290,6 +1342,11 @@ struct ChatView: View { } } + private var searchIsNotBlank: Bool { + get { + searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } @available(iOS 16.0, *) struct MemberLayout: Layout { @@ -1459,18 +1516,26 @@ struct ChatView: View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView( - chat: chat, - chatItem: ci, - scrollToItemId: scrollToItemId, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + chatItem: ci, + scrollToItemId: scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } + } if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { chatItemReactions(ci) .padding(.bottom, 4) @@ -2133,6 +2198,37 @@ struct ChatView: View { } } + func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View { + Button { + onClick() + } label: { + Image(systemName: image) + .resizable() + .frame(width: 13, height: 13) + .padding([alignStart ? .trailing : .leading], 10) + .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) + } + } + + @ViewBuilder + func goToItemButton(_ alignStart: Bool) -> some View { + let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId + if searchIsNotBlank { + goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id) + } + } + } else if let chatTypeApiIdMsgId { + goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + let (chatType, apiId, msgId) = chatTypeApiIdMsgId + ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + } + } + } + } + private struct SelectedChatItem: View { @EnvironmentObject var theme: AppTheme var ciId: Int64 diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 80c5973211..6ded9cae72 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -325,6 +325,7 @@ struct ComposeView: View { @ObservedObject var chat: Chat @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date @Binding var selectedRange: NSRange @State var linkUrl: URL? = nil @@ -434,6 +435,7 @@ struct ComposeView: View { timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, sendButtonColor: chat.chatInfo.incognito ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) : theme.colors.primary @@ -1280,6 +1282,7 @@ struct ComposeView_Previews: PreviewProvider { chat: chat, composeState: $composeState, keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), selectedRange: $selectedRange ) .environmentObject(ChatModel()) @@ -1287,6 +1290,7 @@ struct ComposeView_Previews: PreviewProvider { chat: chat, composeState: $composeState, keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), selectedRange: $selectedRange ) .environmentObject(ChatModel()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 3eeb7ba7f5..d809fd7b76 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var lastUnfocusedDate: Date @Binding var placeholder: String? @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void @@ -42,7 +43,12 @@ struct NativeTextEditor: UIViewRepresentable { onImagesAdded(images) } } - field.setOnFocusChangedListener { focused = $0 } + field.setOnFocusChangedListener { + focused = $0 + if !focused { + lastUnfocusedDate = .now + } + } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.setPlaceholderView() @@ -266,6 +272,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + lastUnfocusedDate: Binding.constant(.now), placeholder: Binding.constant("Placeholder"), selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 9013bb6a88..30767d66ec 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,6 +32,7 @@ struct SendMessageView: View { @State private var holdingVMR = false @Namespace var namespace @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teFont: Font = .body @@ -61,6 +62,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + lastUnfocusedDate: $keyboardHiddenDate, placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), selectedRange: $selectedRange, onImagesAdded: onMediaAdded @@ -441,7 +443,8 @@ struct SendMessageView_Previews: PreviewProvider { selectedRange: $selectedRange, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } VStack { @@ -452,7 +455,8 @@ struct SendMessageView_Previews: PreviewProvider { selectedRange: $selectedRangeEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } } diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 81345e4a03..3d4fb381a6 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -171,8 +171,10 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu visibleItems.last?.index ?? 0 } - /// Whether there is scroll to item in progress or not + /// Whether there is a non-animated scroll to item in progress or not var isScrolling: Bool = false + /// Whether there is an animated scroll to item in progress or not + var isAnimatedScrolling: Bool = false override init() { super.init() @@ -349,11 +351,11 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu if // there is auto scroll in progress and the first item has a higher offset than bottom part // of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to // re-make the first visible item - (listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || + (listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || // the fist visible item previously is hidden now, remove it and move on !isVisible(vis.view) { let newIndex: Int - if listState.isScrolling { + if listState.isAnimatedScrolling { // skip many items to make the scrolling take less time var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0 // if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled @@ -471,7 +473,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } func scrollToItem(_ index: Int, top: Bool = true) { - if index >= listState.items.count || listState.isScrolling { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } if bounds.height == 0 { @@ -498,7 +500,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) var stepSlowdownMultiplier: CGFloat = 1 - while true { + while i < 200 { let up = index > listState.firstVisibleItemIndex if upPrev != up { stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 @@ -522,18 +524,22 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu break } contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + adaptItems(listState.items, false) + snapToContent(animated: false) i += 1 } + adaptItems(listState.items, false) + snapToContent(animated: false) estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) } func scrollToItemAnimated(_ index: Int, top: Bool = true) async { - if index >= listState.items.count || listState.isScrolling { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } - listState.isScrolling = true + listState.isAnimatedScrolling = true defer { - listState.isScrolling = false + listState.isAnimatedScrolling = false } var adjustedOffset = self.contentOffset.y var i = 0 @@ -543,7 +549,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) var stepSlowdownMultiplier: CGFloat = 1 - while true { + while i < 200 { let up = index > listState.firstVisibleItemIndex if upPrev != up { stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 9885811051..2b58abef65 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -20,6 +20,7 @@ struct TerminalView: View { @State var composeState: ComposeState = ComposeState() @State var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @State private var scrolled = false @@ -101,7 +102,8 @@ struct TerminalView: View { sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate ) .padding(.horizontal, 12) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 169a7c21de..a601e60d5f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3261,6 +3261,20 @@ public enum CIForwardedFrom: Decodable, Hashable { } } + public var chatTypeApiIdMsgId: (ChatType, Int64, ChatItem.ID?)? { + switch self { + case .unknown: nil + case let .contact(_, _, contactId, msgId): + if let contactId { + (ChatType.direct, contactId, msgId) + } else { nil } + case let .group(_, _, groupId, msgId): + if let groupId { + (ChatType.group, groupId, msgId) + } else { nil } + } + } + public func text(_ chatType: ChatType) -> LocalizedStringKey { chatType == .local ? (chatName == "" ? "saved" : "saved from \(chatName)")