mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-04 01:41:43 +00:00
ios: open chat on first unread, "scroll" to quoted items that were not loaded
This commit is contained in:
@@ -53,7 +53,11 @@ class ItemsModel: ObservableObject {
|
||||
var itemAdded = false {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
|
||||
|
||||
// set listener here that will be notified on every add/delete of a chat item
|
||||
var chatItemsChangesListener: ChatItemsChangesListener? = nil
|
||||
let chatState = ActiveChatState()
|
||||
|
||||
// Publishes directly to `objectWillChange` publisher,
|
||||
// this will cause reversedChatItems to be rendered without throttling
|
||||
@Published var isLoading = false
|
||||
@@ -82,19 +86,19 @@ class ItemsModel: ObservableObject {
|
||||
await MainActor.run { showLoadingProgress = true }
|
||||
} catch {}
|
||||
}
|
||||
let type = ChatType(rawValue: String(chatId.prefix(1)))!
|
||||
let id = Int64(chatId.suffix(chatId.count - 1))!
|
||||
Task {
|
||||
if let chat = ChatModel.shared.getChat(chatId) {
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(chat: chat)
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.showLoadingProgress = false
|
||||
willNavigate()
|
||||
ChatModel.shared.chatId = chatId
|
||||
}
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(type: type, id: id)
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.showLoadingProgress = false
|
||||
willNavigate()
|
||||
// ChatModel.shared.chatId = id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,6 +901,15 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
protocol ChatItemsChangesListener {
|
||||
// pass null itemIds if the whole chat now read
|
||||
func read(_ itemIds: Set<Int64>?, _ newItems: [ChatItem])
|
||||
func added(_ item: (Int64, Bool), _ index: Int)
|
||||
// itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not)
|
||||
func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem])
|
||||
func cleared()
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
var pcc: PendingContactConnection
|
||||
var connChatUsed: Bool
|
||||
|
||||
@@ -320,35 +320,34 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
|
||||
let loadItemsPerPage = 50
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||
func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
|
||||
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
|
||||
if case let .apiChat(_, chat) = r { return chat.chatItems }
|
||||
if case let .apiChat(_, chat, _) = r { return chat.chatItems }
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
func loadChat(type: ChatType, id: Int64, search: String = "", clearItems: Bool = true) async {
|
||||
// do {
|
||||
let m = ChatModel.shared
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
if clearItems {
|
||||
await MainActor.run { im.reversedChatItems = [] }
|
||||
}
|
||||
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
/*try */await apiLoadMessages(type, id, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 })
|
||||
// await MainActor.run {
|
||||
// im.reversedChatItems = chat.chatItems.reversed()
|
||||
// m.updateChatInfo(chat.chatInfo)
|
||||
// }
|
||||
// } catch let error {
|
||||
// logger.error("loadChat error: \(responseError(error))")
|
||||
// }
|
||||
}
|
||||
|
||||
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
||||
|
||||
@@ -144,7 +144,7 @@ struct SimpleXApp: App {
|
||||
await MainActor.run { chatModel.updateChats(chats) }
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
Task { await loadChat(chat: chat, clearItems: false) }
|
||||
Task { await loadChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, clearItems: false) }
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
await MainActor.run { chatModel.ntfContactRequest = nil }
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//
|
||||
// ChatItemsLoader.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by me on 17.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
let TRIM_KEEP_COUNT = 200
|
||||
|
||||
func apiLoadMessages(
|
||||
_ chatType: ChatType,
|
||||
_ apiId: Int64,
|
||||
_ pagination: ChatPagination,
|
||||
_ chatState: ActiveChatState,
|
||||
_ search: String = "",
|
||||
_ visibleItemIndexesNonReversed: () -> ClosedRange<Int> = { 0 ... 0 }
|
||||
) async {
|
||||
let chat: Chat
|
||||
let navInfo: NavigationInfo
|
||||
do {
|
||||
(chat, navInfo) = try await apiGetChat(type: chatType, id: apiId, pagination: pagination, search: search)
|
||||
} catch let error {
|
||||
logger.error("apiLoadMessages error: \(responseError(error))")
|
||||
return
|
||||
}
|
||||
|
||||
let chatModel = ChatModel.shared
|
||||
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
|
||||
let unreadAfterItemId = chatState.unreadAfterItemId
|
||||
|
||||
let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed())
|
||||
var newItems: [ChatItem] = []
|
||||
switch pagination {
|
||||
case .initial:
|
||||
let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] }
|
||||
if chatModel.getChat(chat.id) == nil {
|
||||
chatModel.addChat(chat)
|
||||
}
|
||||
await MainActor.run {
|
||||
chatModel.chatItemStatuses.removeAll()
|
||||
ItemsModel.shared.reversedChatItems = chat.chatItems.reversed()
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatModel.chatId = chat.chatInfo.id
|
||||
chatState.splits = newSplits
|
||||
if !chat.chatItems.isEmpty {
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
}
|
||||
chatState.totalAfter = navInfo.afterTotal
|
||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||
chatState.unreadAfter = navInfo.afterUnread
|
||||
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||
}
|
||||
case let .before(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
|
||||
guard let indexInCurrentItems else { return }
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
let wasSize = newItems.count
|
||||
let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexesNonReversed
|
||||
)
|
||||
let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0)
|
||||
newItems.insert(contentsOf: chat.chatItems, at: insertAt)
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = modifiedSplits.newSplits
|
||||
chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems)
|
||||
}
|
||||
case let .after(paginationChatItemId, _):
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let indexInCurrentItems = oldItems.firstIndex(where: { $0.id == paginationChatItemId })
|
||||
guard let indexInCurrentItems else { return }
|
||||
|
||||
let mappedItems = mapItemsToIds(chat.chatItems)
|
||||
let newIds = mappedItems.0
|
||||
let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits
|
||||
)
|
||||
let indexToAdd = min(indexInCurrentItems + 1, newItems.count)
|
||||
let indexToAddIsLast = indexToAdd == newItems.count
|
||||
newItems.insert(contentsOf: chat.chatItems, at: indexToAdd)
|
||||
let new: [ChatItem] = newItems
|
||||
let newReversed: [ChatItem] = newItems.reversed()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = newSplits
|
||||
chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new)
|
||||
// loading clear bottom area, updating number of unread items after the newest loaded item
|
||||
if indexToAddIsLast {
|
||||
chatState.unreadAfterNewestLoaded -= unreadInLoaded
|
||||
}
|
||||
}
|
||||
case .around:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
let newSplits = 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()
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = newReversed
|
||||
chatState.splits = [chat.chatItems.last!.id] + newSplits
|
||||
chatState.unreadAfterItemId = chat.chatItems.last!.id
|
||||
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
|
||||
}
|
||||
case .last:
|
||||
newItems.append(contentsOf: oldItems)
|
||||
removeDuplicates(&newItems, chat)
|
||||
newItems.append(contentsOf: chat.chatItems)
|
||||
let items = newItems
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.reversedChatItems = items.reversed()
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatState.unreadAfterNewestLoaded = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class ModifiedSplits {
|
||||
let oldUnreadSplitIndex: Int
|
||||
let newUnreadSplitIndex: Int
|
||||
let trimmedIds: Set<Int64>
|
||||
let newSplits: [Int64]
|
||||
|
||||
init(oldUnreadSplitIndex: Int, newUnreadSplitIndex: Int, trimmedIds: Set<Int64>, newSplits: [Int64]) {
|
||||
self.oldUnreadSplitIndex = oldUnreadSplitIndex
|
||||
self.newUnreadSplitIndex = newUnreadSplitIndex
|
||||
self.trimmedIds = trimmedIds
|
||||
self.newSplits = newSplits
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
_ unreadAfterItemId: Int64,
|
||||
_ newItems: inout [ChatItem],
|
||||
_ newIds: Set<Int64>,
|
||||
_ splits: [Int64],
|
||||
_ visibleItemIndexesNonReversed: () -> ClosedRange<Int>
|
||||
) -> ModifiedSplits {
|
||||
var oldUnreadSplitIndex: Int = -1
|
||||
var newUnreadSplitIndex: Int = -1
|
||||
let visibleItemIndexes = visibleItemIndexesNonReversed()
|
||||
var lastSplitIndexTrimmed: Int? = nil
|
||||
var allowedTrimming = true
|
||||
var index = 0
|
||||
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
|
||||
let trimRange = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT ... newItems.count - TRIM_KEEP_COUNT
|
||||
var trimmedIds = Set<Int64>()
|
||||
let prevItemTrimRange = visibleItemIndexes.upperBound + TRIM_KEEP_COUNT + 1 ... newItems.count - TRIM_KEEP_COUNT
|
||||
var newSplits = splits
|
||||
|
||||
newItems.removeAll(where: {
|
||||
let invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
|
||||
let prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
|
||||
// may disable it after clearing the whole split range
|
||||
if !splits.isEmpty && $0.id == splits.first {
|
||||
// trim only in one split range
|
||||
allowedTrimming = false
|
||||
}
|
||||
let indexInSplits = splits.firstIndex(of: $0.id)
|
||||
if let indexInSplits {
|
||||
lastSplitIndexTrimmed = indexInSplits
|
||||
}
|
||||
if invisibleItemToTrim {
|
||||
if prevItemWasTrimmed {
|
||||
trimmedIds.insert($0.id)
|
||||
} else {
|
||||
newUnreadSplitIndex = index
|
||||
// prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
|
||||
// this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
|
||||
if let lastSplitIndexTrimmed {
|
||||
var new = newSplits
|
||||
new[lastSplitIndexTrimmed] = $0.id
|
||||
newSplits = new
|
||||
} else {
|
||||
newSplits = [$0.id] + newSplits
|
||||
}
|
||||
}
|
||||
}
|
||||
if unreadAfterItemId == $0.id {
|
||||
oldUnreadSplitIndex = index
|
||||
}
|
||||
index += 1
|
||||
return (invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains($0.id)
|
||||
})
|
||||
// will remove any splits that now becomes obsolete because items were merged
|
||||
newSplits = newSplits.filter { split in !newIds.contains(split) && !trimmedIds.contains(split) }
|
||||
return ModifiedSplits(oldUnreadSplitIndex: oldUnreadSplitIndex, newUnreadSplitIndex: newUnreadSplitIndex, trimmedIds: trimmedIds, newSplits: newSplits)
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndModifySplitsOnAfterPagination(
|
||||
_ unreadInLoaded: Int,
|
||||
_ paginationChatItemId: Int64,
|
||||
_ newItems: inout [ChatItem],
|
||||
_ newIds: Set<Int64>,
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64]
|
||||
) -> ([Int64], Int) {
|
||||
var unreadInLoaded = unreadInLoaded
|
||||
var firstItemIdBelowAllSplits: Int64? = nil
|
||||
var splitsToRemove: [Int64] = []
|
||||
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
|
||||
// Currently, it should always load from split range
|
||||
let loadingFromSplitRange = indexInSplitRanges != nil
|
||||
var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
|
||||
Array(splits[indexInSplitRanges + 1 ..< splits.count])
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
newItems.removeAll(where: { new in
|
||||
let duplicate = newIds.contains(new.id)
|
||||
if loadingFromSplitRange && duplicate {
|
||||
if splitsToMerge.contains(new.id) {
|
||||
splitsToMerge.removeAll(where: { $0 == new.id })
|
||||
splitsToRemove.append(new.id)
|
||||
} else if firstItemIdBelowAllSplits == nil && splitsToMerge.isEmpty {
|
||||
// we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
|
||||
firstItemIdBelowAllSplits = new.id
|
||||
}
|
||||
}
|
||||
if duplicate && new.isRcvNew {
|
||||
unreadInLoaded -= 1
|
||||
}
|
||||
return duplicate
|
||||
})
|
||||
var newSplits: [Int64] = []
|
||||
if firstItemIdBelowAllSplits != nil {
|
||||
// no splits anymore, all were merged with bottom items
|
||||
newSplits = []
|
||||
} else {
|
||||
if !splitsToRemove.isEmpty {
|
||||
var new = splits
|
||||
// LALAL TODO make it set
|
||||
new.removeAll(where: { splitsToRemove.contains($0) })
|
||||
newSplits = new
|
||||
}
|
||||
let enlargedSplit = splits.firstIndex(of: paginationChatItemId)
|
||||
if let enlargedSplit {
|
||||
// move the split to the end of loaded items
|
||||
var new = splits
|
||||
new[enlargedSplit] = chat.chatItems.last!.id
|
||||
newSplits = new
|
||||
// Log.d(TAG, "Enlarged split range $newSplits")
|
||||
}
|
||||
}
|
||||
return (newSplits, unreadInLoaded)
|
||||
}
|
||||
|
||||
private func removeDuplicatesAndUpperSplits(
|
||||
_ newItems: inout [ChatItem],
|
||||
_ chat: Chat,
|
||||
_ splits: [Int64],
|
||||
_ visibleItemIndexesNonReversed: () -> ClosedRange<Int>
|
||||
) -> [Int64] {
|
||||
if splits.isEmpty {
|
||||
removeDuplicates(&newItems, chat)
|
||||
return splits
|
||||
}
|
||||
|
||||
var newSplits = splits
|
||||
let visibleItemIndexes = visibleItemIndexesNonReversed()
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
var idsToTrim: [BoxedValue<Set<Int64>>] = []
|
||||
idsToTrim.append(BoxedValue(Set()))
|
||||
var index = 0
|
||||
newItems.removeAll(where: {
|
||||
let duplicate = newIds.contains($0.id)
|
||||
if (!duplicate && visibleItemIndexes.lowerBound > index) {
|
||||
idsToTrim.last?.boxedValue.insert($0.id)
|
||||
}
|
||||
let firstIndex = splits.firstIndex(of: $0.id)
|
||||
if visibleItemIndexes.lowerBound > index, let firstIndex {
|
||||
newSplits.remove(at: firstIndex)
|
||||
// closing previous range. All items in idsToTrim that ends with empty set should be deleted.
|
||||
// Otherwise, the last set should be excluded from trimming because it is in currently visible split range
|
||||
idsToTrim.append(BoxedValue(Set()))
|
||||
}
|
||||
|
||||
index += 1
|
||||
return duplicate
|
||||
})
|
||||
if !idsToTrim.last!.boxedValue.isEmpty {
|
||||
// it has some elements to trim from currently visible range which means the items shouldn't be trimmed
|
||||
// Otherwise, the last set would be empty
|
||||
idsToTrim.removeLast()
|
||||
}
|
||||
let allItemsToDelete = idsToTrim.compactMap { set in set.boxedValue }.joined()
|
||||
if !allItemsToDelete.isEmpty {
|
||||
newItems.removeAll(where: { allItemsToDelete.contains($0.id) })
|
||||
}
|
||||
return newSplits
|
||||
}
|
||||
|
||||
// ids, number of unread items
|
||||
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
|
||||
var unreadInLoaded = 0
|
||||
var ids: Set<Int64> = Set()
|
||||
var i = 0
|
||||
while i < items.count {
|
||||
let item = items[i]
|
||||
ids.insert(item.id)
|
||||
if item.isRcvNew {
|
||||
unreadInLoaded += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return (ids, unreadInLoaded)
|
||||
}
|
||||
|
||||
private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
|
||||
let (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
newItems.removeAll { newIds.contains($0.id) }
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
//
|
||||
// ChatItemsMerger.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 02.12.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct MergedItems {
|
||||
let items: [MergedItem]
|
||||
let splits: [SplitRange]
|
||||
// chat item id, index in list
|
||||
let indexInParentItems: Dictionary<Int64, Int>
|
||||
|
||||
static func create(_ items: [ChatItem], _ unreadCount: Binding<Int>, _ revealedItems: Set<Int64>, _ chatState: ActiveChatState) -> MergedItems {
|
||||
if items.isEmpty {
|
||||
return MergedItems(items: [], splits: [], indexInParentItems: [:])
|
||||
}
|
||||
|
||||
let unreadAfterItemId = chatState.unreadAfterItemId
|
||||
let itemSplits = chatState.splits
|
||||
var mergedItems: [MergedItem] = []
|
||||
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
|
||||
var splitRanges: [SplitRange] = []
|
||||
var indexInParentItems = Dictionary<Int64, Int>()
|
||||
var index = 0
|
||||
var unclosedSplitIndex: Int? = nil
|
||||
var unclosedSplitIndexInParent: Int? = nil
|
||||
var visibleItemIndexInParent = -1
|
||||
var unreadBefore = unreadCount.wrappedValue - chatState.unreadAfterNewestLoaded
|
||||
var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil
|
||||
var lastRangeInReversedForMergedItems: BoxedValue<ClosedRange<Int>>? = nil
|
||||
var recent: MergedItem? = nil
|
||||
while index < items.count {
|
||||
let item = items[index]
|
||||
let prev = index >= 1 ? items[index - 1] : nil
|
||||
let next = index + 1 < items.count ? items[index + 1] : nil
|
||||
let category = item.mergeCategory
|
||||
let itemIsSplit = itemSplits.contains(item.id)
|
||||
|
||||
if item.id == unreadAfterItemId {
|
||||
unreadBefore = unreadCount.wrappedValue - chatState.unreadAfter
|
||||
}
|
||||
if item.isRcvNew {
|
||||
unreadBefore -= 1
|
||||
}
|
||||
|
||||
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
|
||||
if recent != nil, case let .grouped(items, _, _, _, mergeCategory, unreadIds, _) = recent, mergeCategory == category, let first = items.first, !revealedItems.contains(first.item.id) && !itemIsSplit {
|
||||
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
|
||||
recent!.appendItem(listItem)
|
||||
|
||||
if item.isRcvNew {
|
||||
recent!.insertUnreadId(item.id)
|
||||
}
|
||||
if let lastRevealedIdsInMergedItems, let lastRangeInReversedForMergedItems {
|
||||
if revealed {
|
||||
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
|
||||
}
|
||||
lastRangeInReversedForMergedItems.boxedValue = lastRangeInReversedForMergedItems.boxedValue.lowerBound ... index
|
||||
}
|
||||
} else {
|
||||
visibleItemIndexInParent += 1
|
||||
let listItem = ListItem(item: item, prevItem: prev, nextItem: next, unreadBefore: unreadBefore)
|
||||
if item.mergeCategory != nil {
|
||||
if item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == nil {
|
||||
lastRevealedIdsInMergedItems?.boxedValue = revealedItems.contains(item.id) ? [item.id] : []
|
||||
} else if revealed, var lastRevealedIdsInMergedItems {
|
||||
lastRevealedIdsInMergedItems.boxedValue.append(item.id)
|
||||
}
|
||||
lastRangeInReversedForMergedItems?.boxedValue = index ... index
|
||||
recent = MergedItem.grouped(
|
||||
items: [listItem],
|
||||
revealed: revealed,
|
||||
revealedIdsWithinGroup: lastRevealedIdsInMergedItems!,
|
||||
rangeInReversed: lastRangeInReversedForMergedItems!,
|
||||
mergeCategory: item.mergeCategory,
|
||||
unreadIds: item.isRcvNew ? Set(arrayLiteral: item.id) : Set(),
|
||||
startIndexInReversedItems: index
|
||||
)
|
||||
} else {
|
||||
lastRangeInReversedForMergedItems = nil
|
||||
recent = MergedItem.single(
|
||||
item: listItem,
|
||||
startIndexInReversedItems: index
|
||||
)
|
||||
}
|
||||
mergedItems.append(recent!)
|
||||
}
|
||||
if itemIsSplit {
|
||||
// found item that is considered as a split
|
||||
if let unclosedSplitIndex, let unclosedSplitIndexInParent {
|
||||
// it was at least second split in the list
|
||||
splitRanges.append(SplitRange(indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1))
|
||||
}
|
||||
unclosedSplitIndex = index
|
||||
unclosedSplitIndexInParent = visibleItemIndexInParent
|
||||
} else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent {
|
||||
// just one split for the whole list, there will be no more, it's the end
|
||||
splitRanges.append(SplitRange(indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent))
|
||||
}
|
||||
indexInParentItems[item.id] = visibleItemIndexInParent
|
||||
index += 1
|
||||
}
|
||||
return MergedItems(
|
||||
items: mergedItems,
|
||||
splits: splitRanges,
|
||||
indexInParentItems: indexInParentItems
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum MergedItem {
|
||||
// the item that is always single, cannot be grouped and always revealed
|
||||
case single(
|
||||
item: ListItem,
|
||||
startIndexInReversedItems: Int
|
||||
)
|
||||
|
||||
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
|
||||
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
|
||||
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
|
||||
* visible rows in ChatView LazyColumn */
|
||||
case grouped (
|
||||
items: [ListItem],
|
||||
revealed: Bool,
|
||||
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
|
||||
// it's the same list instance for all Grouped items within revealed group
|
||||
/** @see reveal */
|
||||
revealedIdsWithinGroup: BoxedValue<[Int64]>,
|
||||
rangeInReversed: BoxedValue<ClosedRange<Int>>,
|
||||
mergeCategory: CIMergeCategory?,
|
||||
unreadIds: Set<Int64>,
|
||||
startIndexInReversedItems: Int
|
||||
)
|
||||
|
||||
mutating func appendItem(_ item: ListItem) {
|
||||
switch self {
|
||||
case let .grouped(items, revealed, revealedIdsWithinGroup, rangeInReversed, mergeCategory, unreadIds, startIndexInReversedItems):
|
||||
var newItems = items
|
||||
newItems.append(item)
|
||||
self = .grouped(items: newItems, revealed: revealed, revealedIdsWithinGroup: revealedIdsWithinGroup, rangeInReversed: rangeInReversed, mergeCategory: mergeCategory, unreadIds: unreadIds, startIndexInReversedItems: startIndexInReversedItems)
|
||||
case .single: ()
|
||||
}
|
||||
}
|
||||
|
||||
mutating func insertUnreadId(_ id: Int64) {
|
||||
switch self {
|
||||
case let .grouped(items, revealed, revealedIdsWithinGroup, rangeInReversed, mergeCategory, unreadIds, startIndexInReversedItems):
|
||||
var newUnreadIds = unreadIds
|
||||
newUnreadIds.insert(id)
|
||||
self = .grouped(items: items, revealed: revealed, revealedIdsWithinGroup: revealedIdsWithinGroup, rangeInReversed: rangeInReversed, mergeCategory: mergeCategory, unreadIds: newUnreadIds, startIndexInReversedItems: startIndexInReversedItems)
|
||||
case .single: ()
|
||||
}
|
||||
}
|
||||
|
||||
func reveal(reveal: Bool, revealedItems: Binding<Set<Int64>>) {
|
||||
if case .grouped(let items, let revealed, var revealedIdsWithinGroup, let rangeInReversed, let mergeCategory, let unreadIds, let startIndexInReversedItems) = self {
|
||||
var newRevealed = revealedItems.wrappedValue
|
||||
var i = 0
|
||||
if reveal {
|
||||
while i < items.count {
|
||||
newRevealed.insert(items[i].item.id)
|
||||
i += 1
|
||||
}
|
||||
} else {
|
||||
while i < revealedIdsWithinGroup.boxedValue.count {
|
||||
newRevealed.remove(revealedIdsWithinGroup.boxedValue[i])
|
||||
i += 1
|
||||
}
|
||||
revealedIdsWithinGroup.boxedValue.removeAll()
|
||||
}
|
||||
revealedItems.wrappedValue = newRevealed
|
||||
}
|
||||
}
|
||||
|
||||
var startIndexInReversedItems: Int {
|
||||
get {
|
||||
switch self {
|
||||
case let .single(_, startIndexInReversedItems): startIndexInReversedItems
|
||||
case let .grouped(_, _, _, _, _, _, startIndexInReversedItems): startIndexInReversedItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnread() -> Bool {
|
||||
switch self {
|
||||
case let .single(item, _): item.item.isRcvNew
|
||||
case let .grouped(_, _, _, _, _, unreadIds, _): !unreadIds.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func newest() -> ListItem {
|
||||
switch self {
|
||||
case let .single(item, _): item
|
||||
case let .grouped(items, _, _, _, _, _, _): items[0]
|
||||
}
|
||||
}
|
||||
|
||||
func oldest() -> ListItem {
|
||||
switch self {
|
||||
case let .single(item, _): item
|
||||
case let .grouped(items, _, _, _, _, _, _): items[items.count - 1]
|
||||
}
|
||||
}
|
||||
|
||||
func lastIndexInReversed() -> Int {
|
||||
switch self {
|
||||
case .single: startIndexInReversedItems
|
||||
case let .grouped(items, _, _, _, _, _, _): startIndexInReversedItems + items.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SplitRange {
|
||||
/** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
|
||||
* so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
|
||||
* (3, 4 indexes of the splitRange with the split itself at index 3)
|
||||
* */
|
||||
let indexRangeInReversed: ClosedRange<Int>
|
||||
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
|
||||
let indexRangeInParentItems: ClosedRange<Int>
|
||||
}
|
||||
|
||||
struct ListItem {
|
||||
let item: ChatItem
|
||||
let prevItem: ChatItem?
|
||||
let nextItem: ChatItem?
|
||||
// how many unread items before (older than) this one (excluding this one)
|
||||
let unreadBefore: Int
|
||||
}
|
||||
|
||||
class ActiveChatState {
|
||||
var splits: [Int64] = []
|
||||
var unreadAfterItemId: Int64 = -1
|
||||
// total items after unread after item (exclusive)
|
||||
var totalAfter: Int = 0
|
||||
var unreadTotal: Int = 0
|
||||
// exclusive
|
||||
var unreadAfter: Int = 0
|
||||
// exclusive
|
||||
var unreadAfterNewestLoaded: Int = 0
|
||||
|
||||
func moveUnreadAfterItem(_ toItemId: Int64?, _ nonReversedItems: [ChatItem]) {
|
||||
guard let toItemId else { return }
|
||||
let currentIndex = nonReversedItems.firstIndex(where: { $0.id == unreadAfterItemId })
|
||||
let newIndex = nonReversedItems.firstIndex(where: { $0.id == toItemId })
|
||||
guard let currentIndex, let newIndex else {
|
||||
return unreadAfterItemId = toItemId
|
||||
}
|
||||
let unreadDiff = newIndex > currentIndex
|
||||
? -nonReversedItems[currentIndex + 1..<newIndex + 1].filter { $0.isRcvNew }.count
|
||||
: nonReversedItems[newIndex + 1..<currentIndex + 1].filter { $0.isRcvNew }.count
|
||||
unreadAfter += unreadDiff
|
||||
}
|
||||
|
||||
func moveUnreadAfterItem(_ fromIndex: Int, _ toIndex: Int, _ nonReversedItems: [ChatItem]) {
|
||||
if fromIndex == -1 || toIndex == -1 {
|
||||
return unreadAfterItemId = nonReversedItems[toIndex].id
|
||||
}
|
||||
let unreadDiff = toIndex > fromIndex
|
||||
? -nonReversedItems[fromIndex + 1..<toIndex + 1].filter { $0.isRcvNew }.count
|
||||
: nonReversedItems[toIndex + 1..<fromIndex + 1].filter { $0.isRcvNew }.count
|
||||
unreadAfter += unreadDiff
|
||||
}
|
||||
|
||||
func clear() {
|
||||
splits = []
|
||||
unreadAfterItemId = -1
|
||||
totalAfter = 0
|
||||
unreadTotal = 0
|
||||
unreadAfter = 0
|
||||
unreadAfterNewestLoaded = 0
|
||||
}
|
||||
}
|
||||
|
||||
class BoxedValue<T> {
|
||||
var boxedValue : T
|
||||
init(_ value: T) {
|
||||
self.boxedValue = value
|
||||
}
|
||||
}
|
||||
|
||||
extension ReverseList.Controller {
|
||||
func visibleItemIndexesNonReversed(_ mergedItems: Binding<MergedItems>, _ scrollModel: ReverseListScrollModel) -> ClosedRange<Int> {
|
||||
let zero = 0 ... 0
|
||||
if itemCount == 0 {
|
||||
return zero
|
||||
}
|
||||
let listState = getListState() ?? ListState()
|
||||
let items = mergedItems.wrappedValue.items
|
||||
let newest = items.count > listState.firstVisibleItemIndex ? items[listState.firstVisibleItemIndex].startIndexInReversedItems : nil
|
||||
let oldest = items.count > listState.firstVisibleItemIndex ? items[listState.lastVisibleItemIndex].lastIndexInReversed() : nil
|
||||
guard let newest, let oldest else {
|
||||
return zero
|
||||
}
|
||||
let size = ItemsModel.shared.reversedChatItems.count
|
||||
let range = size - oldest ... size - newest
|
||||
if range.lowerBound < 0 || range.upperBound < 0 {
|
||||
return zero
|
||||
}
|
||||
|
||||
// visible items mapped to their underlying data structure which is chatModel.chatItems
|
||||
return range
|
||||
}
|
||||
}
|
||||
|
||||
func recalculateChatStatePositions(_ chatState: ActiveChatState) -> ChatItemsChangesListener {
|
||||
RecalculatePositions(chatState: chatState)
|
||||
}
|
||||
|
||||
private class RecalculatePositions: ChatItemsChangesListener {
|
||||
private let chatState: ActiveChatState
|
||||
|
||||
init(chatState: ActiveChatState) {
|
||||
self.chatState = chatState
|
||||
}
|
||||
|
||||
func read(_ itemIds: Set<Int64>?, _ newItems: [ChatItem]) {
|
||||
guard let itemIds else {
|
||||
// special case when the whole chat became read
|
||||
chatState.unreadTotal = 0
|
||||
chatState.unreadAfter = 0
|
||||
return
|
||||
}
|
||||
var unreadAfterItemIndex: Int = -1
|
||||
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
|
||||
var i = newItems.count - 1
|
||||
var ids = itemIds
|
||||
// intermediate variables to prevent re-setting state value a lot of times without reason
|
||||
var newUnreadTotal = chatState.unreadTotal
|
||||
var newUnreadAfter = chatState.unreadAfter
|
||||
while i >= 0 {
|
||||
let item = newItems[i]
|
||||
if item.id == chatState.unreadAfterItemId {
|
||||
unreadAfterItemIndex = i
|
||||
}
|
||||
if ids.contains(item.id) {
|
||||
// was unread, now this item is read
|
||||
if (unreadAfterItemIndex == -1) {
|
||||
newUnreadAfter -= 1
|
||||
}
|
||||
newUnreadTotal -= 1
|
||||
ids.remove(item.id)
|
||||
if ids.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
chatState.unreadTotal = newUnreadTotal
|
||||
chatState.unreadAfter = newUnreadAfter
|
||||
}
|
||||
func added(_ item: (Int64, Bool), _ index: Int) {
|
||||
if item.1 {
|
||||
chatState.unreadAfter += 1
|
||||
chatState.unreadTotal += 1
|
||||
}
|
||||
}
|
||||
func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
|
||||
var newSplits: [Int64] = []
|
||||
for split in chatState.splits {
|
||||
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
|
||||
// deleted the item that was right before the split between items, find newer item so it will act like the split
|
||||
if let index {
|
||||
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
|
||||
let newSplit = newItems.count > idx ? newItems[idx].id : nil
|
||||
// it the whole section is gone and splits overlap, don't add it at all
|
||||
if let newSplit, !newSplits.contains(newSplit) {
|
||||
newSplits.append(newSplit)
|
||||
}
|
||||
} else {
|
||||
newSplits.append(split)
|
||||
}
|
||||
}
|
||||
chatState.splits = newSplits
|
||||
|
||||
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId })
|
||||
// unread after item was removed
|
||||
if let index {
|
||||
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
|
||||
var newUnreadAfterItemId = newItems.count > idx ? newItems[idx].id : nil
|
||||
let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
|
||||
if newUnreadAfterItemId == nil {
|
||||
// everything on top (including unread after item) were deleted, take top item as unread after id
|
||||
newUnreadAfterItemId = newItems.first?.id
|
||||
}
|
||||
if let newUnreadAfterItemId {
|
||||
chatState.unreadAfterItemId = newUnreadAfterItemId
|
||||
chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
|
||||
chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
|
||||
chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
|
||||
if newUnreadAfterItemWasNull {
|
||||
// since the unread after item was moved one item after initial position, adjust counters accordingly
|
||||
if newItems.first?.isRcvNew == true {
|
||||
chatState.unreadTotal += 1
|
||||
chatState.unreadAfter -= 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// all items were deleted, 0 items in chatItems
|
||||
chatState.unreadAfterItemId = -1
|
||||
chatState.totalAfter = 0
|
||||
chatState.unreadTotal = 0
|
||||
chatState.unreadAfter = 0
|
||||
}
|
||||
} else {
|
||||
chatState.totalAfter -= itemIds.count
|
||||
}
|
||||
}
|
||||
func cleared() { chatState.clear() }
|
||||
}
|
||||
@@ -389,7 +389,7 @@ struct ChatView: View {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
searchFocussed = false
|
||||
Task { await loadChat(chat: chat) }
|
||||
Task { await loadChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -445,7 +445,7 @@ struct ChatView: View {
|
||||
.padding(.vertical, -InvertedTableView.inset)
|
||||
.onTapGesture { hideKeyboard() }
|
||||
.onChange(of: searchText) { _ in
|
||||
Task { await loadChat(chat: chat, search: searchText) }
|
||||
Task { await loadChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, search: searchText) }
|
||||
}
|
||||
.onChange(of: im.itemAdded) { added in
|
||||
if added {
|
||||
|
||||
@@ -339,14 +339,8 @@ struct GroupMemberInfoView: View {
|
||||
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
Task {
|
||||
do {
|
||||
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||
chatModel.addChat(chat)
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)") {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,8 +354,7 @@ struct GroupMemberInfoView: View {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
ItemsModel.shared.loadOpenChat("@\(memberContact.id)") {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
|
||||
@@ -43,11 +43,11 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
/// Controller, which hosts SwiftUI cells
|
||||
class Controller: UITableViewController {
|
||||
public class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private var itemCount: Int = 0
|
||||
var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
@@ -218,13 +218,15 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) })
|
||||
let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) })
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
if let firstVisible {
|
||||
representer.items[firstVisible.item].id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
|
||||
return ListState(scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId, firstVisibleItemIndex: firstVisible?.item ?? 0, lastVisibleItemIndex: lastVisible?.item ?? 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -280,11 +282,21 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
typealias ListState = (
|
||||
scrollOffset: Double,
|
||||
topItemDate: Date?,
|
||||
bottomItemId: ChatItem.ID?
|
||||
)
|
||||
class ListState {
|
||||
let scrollOffset: Double
|
||||
let topItemDate: Date?
|
||||
let bottomItemId: ChatItem.ID?
|
||||
let firstVisibleItemIndex: Int
|
||||
let lastVisibleItemIndex: Int
|
||||
|
||||
init(scrollOffset: Double = 0, topItemDate: Date? = nil, bottomItemId: ChatItem.ID? = nil, firstVisibleItemIndex: Int = 0, lastVisibleItemIndex: Int = 0) {
|
||||
self.scrollOffset = scrollOffset
|
||||
self.topItemDate = topItemDate
|
||||
self.bottomItemId = bottomItemId
|
||||
self.firstVisibleItemIndex = firstVisibleItemIndex
|
||||
self.lastVisibleItemIndex = lastVisibleItemIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages ``ReverseList`` scrolling
|
||||
class ReverseListScrollModel: ObservableObject {
|
||||
|
||||
@@ -199,6 +199,8 @@
|
||||
8C8118722C220B5B00E6FC94 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8C8118712C220B5B00E6FC94 /* Yams */; };
|
||||
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; };
|
||||
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; };
|
||||
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */; };
|
||||
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; };
|
||||
8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; };
|
||||
8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; };
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
@@ -549,6 +551,8 @@
|
||||
8C852B072C1086D100BA61E8 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
|
||||
8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = "<group>"; };
|
||||
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = "<group>"; };
|
||||
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = "<group>"; };
|
||||
8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = "<group>"; };
|
||||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
@@ -746,6 +750,8 @@
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
|
||||
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */,
|
||||
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
@@ -1516,6 +1522,7 @@
|
||||
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
|
||||
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
|
||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
|
||||
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */,
|
||||
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
|
||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
|
||||
8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */,
|
||||
@@ -1554,6 +1561,7 @@
|
||||
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */,
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */,
|
||||
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */,
|
||||
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */,
|
||||
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */,
|
||||
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
|
||||
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
|
||||
|
||||
@@ -203,7 +203,7 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
||||
let jChats = jApiChats["chats"] as? NSArray {
|
||||
let chats = jChats.map { jChat in
|
||||
if let chatData = try? parseChatData(jChat) {
|
||||
return chatData
|
||||
return chatData.0
|
||||
}
|
||||
return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "")
|
||||
}
|
||||
@@ -213,8 +213,9 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
||||
if let jApiChat = jResp["apiChat"] as? NSDictionary,
|
||||
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
|
||||
let jChat = jApiChat["chat"] as? NSDictionary,
|
||||
let chat = try? parseChatData(jChat) {
|
||||
return .apiChat(user: user, chat: chat)
|
||||
let jNavInfo = jApiChat["navInfo"] as? NSDictionary,
|
||||
let (chat, navInfo) = try? parseChatData(jChat, jNavInfo) {
|
||||
return .apiChat(user: user, chat: chat, navInfo: navInfo)
|
||||
}
|
||||
} else if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
@@ -247,10 +248,11 @@ private func errorJson(_ jDict: NSDictionary) -> String? {
|
||||
}
|
||||
}
|
||||
|
||||
func parseChatData(_ jChat: Any) throws -> ChatData {
|
||||
func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
|
||||
let jChatDict = jChat as! NSDictionary
|
||||
let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!)
|
||||
let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!)
|
||||
let navInfo: NavigationInfo = jNavInfo == nil ? NavigationInfo() : try decodeObject((jNavInfo as! NSDictionary)["navInfo"]!)
|
||||
let jChatItems = jChatDict["chatItems"] as! NSArray
|
||||
let chatItems = jChatItems.map { jCI in
|
||||
if let ci: ChatItem = try? decodeObject(jCI) {
|
||||
@@ -262,7 +264,7 @@ func parseChatData(_ jChat: Any) throws -> ChatData {
|
||||
json: serializeJSON(jCI, options: .prettyPrinted) ?? ""
|
||||
)
|
||||
}
|
||||
return ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats)
|
||||
return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo)
|
||||
}
|
||||
|
||||
func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
|
||||
|
||||
@@ -560,7 +560,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatStopped
|
||||
case chatSuspended
|
||||
case apiChats(user: UserRef, chats: [ChatData])
|
||||
case apiChat(user: UserRef, chat: ChatData)
|
||||
case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo)
|
||||
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
case serverOperatorConditions(conditions: ServerOperatorConditions)
|
||||
@@ -908,7 +908,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatStopped: return noDetails
|
||||
case .chatSuspended: return noDetails
|
||||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
||||
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
|
||||
case let .apiChat(u, chat, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))")
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
|
||||
@@ -1156,12 +1156,16 @@ public enum ChatPagination {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
case before(chatItemId: Int64, count: Int)
|
||||
case around(chatItemId: Int64, count: Int)
|
||||
case initial(count: Int)
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .last(count): return "count=\(count)"
|
||||
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
||||
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
|
||||
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
|
||||
case let .initial(count): return "initial=\(count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2003,6 +2007,11 @@ public struct ChatSettings: Codable, Hashable {
|
||||
public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
|
||||
}
|
||||
|
||||
public struct NavigationInfo: Decodable {
|
||||
public var afterUnread: Int = 0
|
||||
public var afterTotal: Int = 0
|
||||
}
|
||||
|
||||
public enum MsgFilter: String, Codable, Hashable {
|
||||
case none
|
||||
case all
|
||||
|
||||
Reference in New Issue
Block a user