ios: go to forwarded item or search result (#5679)

* ios: go to forwarded item or search result

* react on touch

* changes
This commit is contained in:
Stanislav Dmitrenko 2025-02-28 21:45:24 +07:00 committed by GitHub
parent b482d4d812
commit fefddb3b5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 228 additions and 64 deletions

View file

@ -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 { class ChatTagsModel: ObservableObject {
@ -259,6 +275,7 @@ final class ChatModel: ObservableObject {
@Published var deletedChats: Set<String> = [] @Published var deletedChats: Set<String> = []
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var openAroundItemId: ChatItem.ID? = nil
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:] var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GMember] = [] @Published var groupMembers: [GMember] = []

View file

@ -338,7 +338,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) 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 m = ChatModel.shared
let im = ItemsModel.shared let im = ItemsModel.shared
m.chatItemStatuses = [:] m.chatItemStatuses = [:]
@ -348,7 +348,7 @@ func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) asyn
ItemsModel.shared.chatItemsChangesListener.cleared() 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 { func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {

View file

@ -16,6 +16,7 @@ func apiLoadMessages(
_ pagination: ChatPagination, _ pagination: ChatPagination,
_ chatState: ActiveChatState, _ chatState: ActiveChatState,
_ search: String = "", _ search: String = "",
_ openAroundItemId: ChatItem.ID? = nil,
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 } _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
) async { ) async {
let chat: Chat 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 // 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 paginationIsInitial = switch pagination { case .initial: true; default: false }
let paginationIsLast = switch pagination { case .last: 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 return
} }
@ -102,8 +104,13 @@ func apiLoadMessages(
} }
} }
case .around: case .around:
newItems.append(contentsOf: oldItems) let newSplits: [Int64]
let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) 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 // currently, items will always be added on top, which is index 0
newItems.insert(contentsOf: chat.chatItems, at: 0) newItems.insert(contentsOf: chat.chatItems, at: 0)
let newReversed: [ChatItem] = newItems.reversed() let newReversed: [ChatItem] = newItems.reversed()
@ -114,8 +121,15 @@ func apiLoadMessages(
chatState.totalAfter = navInfo.afterTotal chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread 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: case .last:
newItems.append(contentsOf: oldItems) newItems.append(contentsOf: oldItems)

View file

@ -46,7 +46,7 @@ func preloadIfNeeded(
loadItems: @escaping (Bool, ChatPagination) async -> Bool loadItems: @escaping (Bool, ChatPagination) async -> Bool
) { ) {
let state = PreloadState.shared let state = PreloadState.shared
guard !listState.isScrolling, guard !listState.isScrolling && !listState.isAnimatedScrolling,
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count, state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
!state.preloading, !state.preloading,
listState.totalItemsCount > 0 listState.totalItemsCount > 0

View file

@ -29,6 +29,7 @@ struct ChatView: View {
@State private var composeState = ComposeState() @State private var composeState = ComposeState()
@State private var selectedRange = NSRange() @State private var selectedRange = NSRange()
@State private var keyboardVisible = false @State private var keyboardVisible = false
@State private var keyboardHiddenDate = Date.now
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile? @State private var customUserProfile: Profile?
@State private var connectionCode: String? @State private var connectionCode: String?
@ -37,7 +38,7 @@ struct ChatView: View {
@State private var requestedTopScroll = false @State private var requestedTopScroll = false
@State private var loadingBottomItems = false @State private var loadingBottomItems = false
@State private var requestedBottomScroll = false @State private var requestedBottomScroll = false
@State private var searchMode = false @State private var showSearch = false
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon // opening GroupMemberInfoView on member icon
@ -54,11 +55,8 @@ struct ChatView: View {
@State private var allowLoadMoreItems: Bool = false @State private var allowLoadMoreItems: Bool = false
@State private var ignoreLoadingRequests: Int64? = nil @State private var ignoreLoadingRequests: Int64? = nil
@State private var animatedScrollingInProgress: Bool = false @State private var animatedScrollingInProgress: Bool = false
@State private var updateMergedItemsTask: Task<Void, Never>? = nil
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
private let useItemsUpdateTask = false
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero) @State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@ -101,6 +99,7 @@ struct ChatView: View {
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
selectedRange: $selectedRange selectedRange: $selectedRange
) )
.disabled(!cInfo.sendMsgEnabled) .disabled(!cInfo.sendMsgEnabled)
@ -131,7 +130,7 @@ struct ChatView: View {
} }
.safeAreaInset(edge: .top) { .safeAreaInset(edge: .top) {
VStack(spacing: .zero) { VStack(spacing: .zero) {
if searchMode { searchToolbar() } if showSearch { searchToolbar() }
Divider() Divider()
} }
.background(ToolbarMaterial.material(toolbarMaterial)) .background(ToolbarMaterial.material(toolbarMaterial))
@ -232,15 +231,45 @@ struct ChatView: View {
scrollView.listState.onUpdateListener = onChatItemsUpdated scrollView.listState.onUpdateListener = onChatItemsUpdated
initChatView() initChatView()
theme = buildTheme() 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) scrollView.scrollToItem(unreadIndex)
} else { } else {
scrollView.scrollToBottom() scrollView.scrollToBottom()
} }
if chatModel.openAroundItemId != nil {
chatModel.openAroundItemId = nil
}
} else { } else {
dismiss() 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 { .onDisappear {
VideoPlayerView.players.removeAll() VideoPlayerView.players.removeAll()
stopAudioPlayer() stopAudioPlayer()
@ -429,9 +458,13 @@ struct ChatView: View {
index = mergedItems.boxedValue.indexInParentItems[itemId] index = mergedItems.boxedValue.indexInParentItems[itemId]
} }
if let index { if let index {
await MainActor.run { animatedScrollingInProgress = true } closeKeyboardAndRun {
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) Task {
await MainActor.run { animatedScrollingInProgress = false } await MainActor.run { animatedScrollingInProgress = true }
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
await MainActor.run { animatedScrollingInProgress = false }
}
}
} }
} catch { } catch {
logger.error("Error scrolling to item: \(error)") logger.error("Error scrolling to item: \(error)")
@ -460,9 +493,7 @@ struct ChatView: View {
.cornerRadius(10.0) .cornerRadius(10.0)
Button ("Cancel") { Button ("Cancel") {
searchText = "" closeSearch()
searchMode = false
searchFocussed = false
Task { await loadChat(chat: chat) } Task { await loadChat(chat: chat) }
} }
} }
@ -517,7 +548,9 @@ struct ChatView: View {
showChatInfoSheet: $showChatInfoSheet, showChatInfoSheet: $showChatInfoSheet,
revealedItems: $revealedItems, revealedItems: $revealedItems,
selectedChatItems: $selectedChatItems, selectedChatItems: $selectedChatItems,
forwardedChatItems: $forwardedChatItems forwardedChatItems: $forwardedChatItems,
searchText: $searchText,
closeKeyboardAndRun: closeKeyboardAndRun
) )
// crashes on Cell size calculation without this line // crashes on Cell size calculation without this line
.environmentObject(ChatModel.shared) .environmentObject(ChatModel.shared)
@ -535,25 +568,10 @@ struct ChatView: View {
} }
} }
.onChange(of: im.reversedChatItems) { items in .onChange(of: im.reversedChatItems) { items in
updateMergedItemsTask?.cancel() mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
if useItemsUpdateTask { scrollView.updateItems(mergedItems.boxedValue.items)
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)
}
} }
.onChange(of: revealedItems) { revealed in .onChange(of: revealedItems) { revealed in
updateMergedItemsTask?.cancel()
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
scrollView.updateItems(mergedItems.boxedValue.items) scrollView.updateItems(mergedItems.boxedValue.items)
} }
@ -567,6 +585,7 @@ struct ChatView: View {
.padding(.vertical, -100) .padding(.vertical, -100)
.onTapGesture { hideKeyboard() } .onTapGesture { hideKeyboard() }
.onChange(of: searchText) { s in .onChange(of: searchText) { s in
guard showSearch else { return }
Task { Task {
await loadChat(chat: chat, search: s) await loadChat(chat: chat, search: s)
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
@ -880,11 +899,29 @@ struct ChatView: View {
} }
private func focusSearch() { private func focusSearch() {
searchMode = true showSearch = true
searchFocussed = true searchFocussed = true
searchText = "" 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 { private func addMembersButton() -> some View {
Button { Button {
if case let .group(gInfo) = chat.chatInfo { if case let .group(gInfo) = chat.chatInfo {
@ -1079,6 +1116,7 @@ struct ChatView: View {
pagination, pagination,
im.chatState, im.chatState,
searchText, searchText,
nil,
{ visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
) )
return true return true
@ -1136,10 +1174,14 @@ struct ChatView: View {
@State private var showChatItemInfoSheet: Bool = false @State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo? @State private var chatItemInfo: ChatItemInfo?
@State private var msgWidth: CGFloat = 0 @State private var msgWidth: CGFloat = 0
@State private var touchInProgress: Bool = false
@Binding var selectedChatItems: Set<Int64>? @Binding var selectedChatItems: Set<Int64>?
@Binding var forwardedChatItems: [ChatItem] @Binding var forwardedChatItems: [ChatItem]
@Binding var searchText: String
var closeKeyboardAndRun: (@escaping () -> Void) -> Void
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var markedRead = false @State private var markedRead = false
@State private var markReadTask: Task<Void, Never>? = nil @State private var markReadTask: Task<Void, Never>? = nil
@ -1257,6 +1299,16 @@ struct ChatView: View {
markedRead = false markedRead = false
} }
.actionSheet(item: $actionSheet) { $0.actionSheet } .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<Int>) -> ([ChatItem.ID], Int) { private func unreadItemIds(_ range: ClosedRange<Int>) -> ([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, *) @available(iOS 16.0, *)
struct MemberLayout: Layout { struct MemberLayout: Layout {
@ -1459,18 +1516,26 @@ struct ChatView: View {
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
VStack(alignment: alignment.horizontal, spacing: 3) { VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView( HStack {
chat: chat, if ci.chatDir.sent {
chatItem: ci, goToItemButton(true)
scrollToItemId: scrollToItemId, }
maxWidth: maxWidth, ChatItemView(
allowMenu: $allowMenu chat: chat,
) chatItem: ci,
.environment(\.revealed, revealed) scrollToItemId: scrollToItemId,
.environment(\.showTimestamp, itemSeparation.timestamp) maxWidth: maxWidth,
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) allowMenu: $allowMenu
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } )
.accessibilityLabel("") .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 { if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions(ci) chatItemReactions(ci)
.padding(.bottom, 4) .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 { private struct SelectedChatItem: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var ciId: Int64 var ciId: Int64

View file

@ -325,6 +325,7 @@ struct ComposeView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date
@Binding var selectedRange: NSRange @Binding var selectedRange: NSRange
@State var linkUrl: URL? = nil @State var linkUrl: URL? = nil
@ -434,6 +435,7 @@ struct ComposeView: View {
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
sendButtonColor: chat.chatInfo.incognito sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: theme.colors.primary : theme.colors.primary
@ -1280,6 +1282,7 @@ struct ComposeView_Previews: PreviewProvider {
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
keyboardVisible: Binding.constant(true), keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now),
selectedRange: $selectedRange selectedRange: $selectedRange
) )
.environmentObject(ChatModel()) .environmentObject(ChatModel())
@ -1287,6 +1290,7 @@ struct ComposeView_Previews: PreviewProvider {
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
keyboardVisible: Binding.constant(true), keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now),
selectedRange: $selectedRange selectedRange: $selectedRange
) )
.environmentObject(ChatModel()) .environmentObject(ChatModel())

View file

@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool @Binding var disableEditing: Bool
@Binding var height: CGFloat @Binding var height: CGFloat
@Binding var focused: Bool @Binding var focused: Bool
@Binding var lastUnfocusedDate: Date
@Binding var placeholder: String? @Binding var placeholder: String?
@Binding var selectedRange: NSRange @Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void let onImagesAdded: ([UploadContent]) -> Void
@ -42,7 +43,12 @@ struct NativeTextEditor: UIViewRepresentable {
onImagesAdded(images) onImagesAdded(images)
} }
} }
field.setOnFocusChangedListener { focused = $0 } field.setOnFocusChangedListener {
focused = $0
if !focused {
lastUnfocusedDate = .now
}
}
field.delegate = field field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
field.setPlaceholderView() field.setPlaceholderView()
@ -266,6 +272,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false), disableEditing: Binding.constant(false),
height: Binding.constant(100), height: Binding.constant(100),
focused: Binding.constant(false), focused: Binding.constant(false),
lastUnfocusedDate: Binding.constant(.now),
placeholder: Binding.constant("Placeholder"), placeholder: Binding.constant("Placeholder"),
selectedRange: Binding.constant(NSRange(location: 0, length: 0)), selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
onImagesAdded: { _ in } onImagesAdded: { _ in }

View file

@ -32,6 +32,7 @@ struct SendMessageView: View {
@State private var holdingVMR = false @State private var holdingVMR = false
@Namespace var namespace @Namespace var namespace
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date
var sendButtonColor = Color.accentColor var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teHeight: CGFloat = NativeTextEditor.minHeight
@State private var teFont: Font = .body @State private var teFont: Font = .body
@ -61,6 +62,7 @@ struct SendMessageView: View {
disableEditing: $composeState.inProgress, disableEditing: $composeState.inProgress,
height: $teHeight, height: $teHeight,
focused: $keyboardVisible, focused: $keyboardVisible,
lastUnfocusedDate: $keyboardHiddenDate,
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
selectedRange: $selectedRange, selectedRange: $selectedRange,
onImagesAdded: onMediaAdded onImagesAdded: onMediaAdded
@ -441,7 +443,8 @@ struct SendMessageView_Previews: PreviewProvider {
selectedRange: $selectedRange, selectedRange: $selectedRange,
sendMessage: { _ in }, sendMessage: { _ in },
onMediaAdded: { _ in }, onMediaAdded: { _ in },
keyboardVisible: Binding.constant(true) keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now)
) )
} }
VStack { VStack {
@ -452,7 +455,8 @@ struct SendMessageView_Previews: PreviewProvider {
selectedRange: $selectedRangeEditing, selectedRange: $selectedRangeEditing,
sendMessage: { _ in }, sendMessage: { _ in },
onMediaAdded: { _ in }, onMediaAdded: { _ in },
keyboardVisible: Binding.constant(true) keyboardVisible: Binding.constant(true),
keyboardHiddenDate: Binding.constant(Date.now)
) )
} }
} }

View file

@ -171,8 +171,10 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
visibleItems.last?.index ?? 0 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 var isScrolling: Bool = false
/// Whether there is an animated scroll to item in progress or not
var isAnimatedScrolling: Bool = false
override init() { override init() {
super.init() super.init()
@ -349,11 +351,11 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
if // there is auto scroll in progress and the first item has a higher offset than bottom part 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 // 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 // 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 // the fist visible item previously is hidden now, remove it and move on
!isVisible(vis.view) { !isVisible(vis.view) {
let newIndex: Int let newIndex: Int
if listState.isScrolling { if listState.isAnimatedScrolling {
// skip many items to make the scrolling take less time // skip many items to make the scrolling take less time
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0 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 // 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<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
} }
func scrollToItem(_ index: Int, top: Bool = true) { func scrollToItem(_ index: Int, top: Bool = true) {
if index >= listState.items.count || listState.isScrolling { if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
return return
} }
if bounds.height == 0 { if bounds.height == 0 {
@ -498,7 +500,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
var stepSlowdownMultiplier: CGFloat = 1 var stepSlowdownMultiplier: CGFloat = 1
while true { while i < 200 {
let up = index > listState.firstVisibleItemIndex let up = index > listState.firstVisibleItemIndex
if upPrev != up { if upPrev != up {
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
@ -522,18 +524,22 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
break break
} }
contentOffset = CGPointMake(contentOffset.x, adjustedOffset) contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
adaptItems(listState.items, false)
snapToContent(animated: false)
i += 1 i += 1
} }
adaptItems(listState.items, false)
snapToContent(animated: false)
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
} }
func scrollToItemAnimated(_ index: Int, top: Bool = true) async { 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 return
} }
listState.isScrolling = true listState.isAnimatedScrolling = true
defer { defer {
listState.isScrolling = false listState.isAnimatedScrolling = false
} }
var adjustedOffset = self.contentOffset.y var adjustedOffset = self.contentOffset.y
var i = 0 var i = 0
@ -543,7 +549,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
var stepSlowdownMultiplier: CGFloat = 1 var stepSlowdownMultiplier: CGFloat = 1
while true { while i < 200 {
let up = index > listState.firstVisibleItemIndex let up = index > listState.firstVisibleItemIndex
if upPrev != up { if upPrev != up {
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5

View file

@ -20,6 +20,7 @@ struct TerminalView: View {
@State var composeState: ComposeState = ComposeState() @State var composeState: ComposeState = ComposeState()
@State var selectedRange = NSRange() @State var selectedRange = NSRange()
@State private var keyboardVisible = false @State private var keyboardVisible = false
@State private var keyboardHiddenDate = Date.now
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var terminalItem: TerminalItem? @State private var terminalItem: TerminalItem?
@State private var scrolled = false @State private var scrolled = false
@ -101,7 +102,8 @@ struct TerminalView: View {
sendMessage: { _ in consoleSendMessage() }, sendMessage: { _ in consoleSendMessage() },
showVoiceMessageButton: false, showVoiceMessageButton: false,
onMediaAdded: { _ in }, onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate
) )
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }

View file

@ -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 { public func text(_ chatType: ChatType) -> LocalizedStringKey {
chatType == .local chatType == .local
? (chatName == "" ? "saved" : "saved from \(chatName)") ? (chatName == "" ? "saved" : "saved from \(chatName)")