mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
wip
This commit is contained in:
@@ -43,9 +43,23 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
/// Represents a gap in a list of chat items, indicating where data is missing and should be loaded.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - itemId: The unique identifier of the last item in the loaded list before the gap.
|
||||
/// This ID corresponds to an item in the chat history, ordered from older to newer items.
|
||||
/// It is typically used when loading items via .around or .initial pagination when loading items
|
||||
/// - indexRange: The range of indexes within `reversedChatItems` array that
|
||||
/// represents the gap. The first index in this range is the position of the gap itself.
|
||||
/// For instance, if the array `[0, 1, 2, -100-, 101]` has a gap at index 3, `indexRange`
|
||||
/// would be `3..<5`, indicating the gap starts at index 3.
|
||||
/// - indexRangeInParentItems: The range of indexes in the `ReverseList` or parent UI component
|
||||
/// that considers revealed or hidden items, showing where the gap appears in the visible list.
|
||||
/// The first index in this range points to where the gap starts in the UI.
|
||||
struct ChatGap {
|
||||
let index: Int
|
||||
let size: Int
|
||||
let itemId: Int64
|
||||
let indexRange: Range<Int>
|
||||
let indexRangeInParentItems: Range<Int>
|
||||
}
|
||||
|
||||
class ItemsModel: ObservableObject {
|
||||
@@ -63,14 +77,12 @@ class ItemsModel: ObservableObject {
|
||||
var itemAdded = false {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
var gap: ChatGap? = nil {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
|
||||
// Publishes directly to `objectWillChange` publisher,
|
||||
// this will cause reversedChatItems to be rendered without throttling
|
||||
@Published var isLoading = false
|
||||
@Published var showLoadingProgress = false
|
||||
@State var gaps: [ChatItem.ID] = []
|
||||
|
||||
init() {
|
||||
publisher
|
||||
@@ -99,7 +111,9 @@ class ItemsModel: ObservableObject {
|
||||
if let chat = ChatModel.shared.getChat(chatId) {
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(chat: chat)
|
||||
if let gap = await loadChat(chat: chat) {
|
||||
self.gaps = [gap]
|
||||
}
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
await MainActor.run {
|
||||
|
||||
@@ -322,15 +322,15 @@ let loadItemsPerPage = 100
|
||||
let preloadItem = 25
|
||||
let idealChatListSize = 300
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> (Chat, Int?) {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat, _) = r { return Chat.init(chat) }
|
||||
if case let .apiChat(_, chat, gap) = r { return (Chat.init(chat), gap) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> ([ChatItem], Int?) {
|
||||
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, gap) = r { return (chat.chatItems, gap) }
|
||||
if case .chatCmdError(_, _) = r {
|
||||
if case .chatError(_, let chatError) = r {
|
||||
if case .errorStore(let storeError) = chatError {
|
||||
@@ -343,7 +343,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async -> ChatItem.ID? {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
@@ -352,17 +352,23 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
if clearItems {
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = []
|
||||
im.gap = nil
|
||||
}
|
||||
}
|
||||
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
let (chat, gap) = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
}
|
||||
|
||||
return if gap != nil {
|
||||
chat.chatItems[loadItemsPerPage].id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
||||
|
||||
@@ -51,11 +51,11 @@ struct FramedItemView: View {
|
||||
if let itemId = qi.itemId {
|
||||
if !scrollToItem(itemId) {
|
||||
Task {
|
||||
if await loadItemsAround(chat.chatInfo, itemId) != nil {
|
||||
await MainActor.run {
|
||||
let _ = scrollToItem(itemId)
|
||||
}
|
||||
}
|
||||
//if await loadItemsAround(chat.chatInfo, itemId) != nil {
|
||||
// await MainActor.run {
|
||||
// let _ = scrollToItem(itemId)
|
||||
//}
|
||||
//}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -342,47 +342,7 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func loadItemsAround(_ cInfo: ChatInfo, _ chatItemId: Int64) async -> [ChatItem]? {
|
||||
do {
|
||||
let im = ItemsModel.shared
|
||||
var reversedPage = Array<ChatItem>()
|
||||
let pagination: ChatPagination = .around(chatItemId: chatItemId, count: loadItemsPerPage * 2)
|
||||
let chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: pagination,
|
||||
search: ""
|
||||
)
|
||||
|
||||
let dedupedChatItems = chatItems.filter { !im.chatItemIds.contains($0.id) }
|
||||
reversedPage.append(contentsOf: dedupedChatItems.reversed())
|
||||
|
||||
var itemsToDrop = Set<Int64>()
|
||||
await MainActor.run {
|
||||
if let g = im.gap {
|
||||
itemsToDrop = Set(im.reversedChatItems.suffix(g.size).map { $0.id })
|
||||
}
|
||||
|
||||
let itemCount = im.reversedChatItems.count
|
||||
im.reversedChatItems.append(contentsOf: reversedPage)
|
||||
}
|
||||
|
||||
if (itemsToDrop.count > 0) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
Task {
|
||||
im.reversedChatItems.removeAll(where: { itemsToDrop.contains($0.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reversedPage
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
|
||||
|
||||
@@ -77,7 +77,7 @@ struct ChatView: View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
chatItemsList()
|
||||
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat, onScrollToBottom: onScrollToBottom)
|
||||
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
|
||||
}
|
||||
connectingText()
|
||||
if selectedChatItems == nil {
|
||||
@@ -481,14 +481,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func onScrollToBottom() {
|
||||
scrollModel.scrollToBottom()
|
||||
if let g = im.gap {
|
||||
let sliceSize = min(g.index, idealChatListSize)
|
||||
im.reversedChatItems = Array(im.reversedChatItems[..<sliceSize])
|
||||
}
|
||||
}
|
||||
|
||||
private func getFirstUnreadItem() -> ChatItem? {
|
||||
var maybeItem: ChatItem? = nil
|
||||
for i in stride(from: im.reversedChatItems.count - 1, through: 0, by: -1) {
|
||||
@@ -535,9 +527,6 @@ struct ChatView: View {
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if unreadBelow > 0, let g = im.gap, g.size > 0, g.index < bottomItemIndex {
|
||||
unreadBelow += g.size
|
||||
}
|
||||
let date: Date? =
|
||||
if let topItemDate = listState.topItemDate {
|
||||
Calendar.current.startOfDay(for: topItemDate)
|
||||
@@ -599,7 +588,6 @@ struct ChatView: View {
|
||||
let theme: AppTheme
|
||||
let scrollModel: ReverseListScrollModel
|
||||
let chat: Chat
|
||||
let onScrollToBottom: () -> Void
|
||||
@ObservedObject var model = FloatingButtonModel.shared
|
||||
|
||||
var body: some View {
|
||||
@@ -640,14 +628,14 @@ struct ChatView: View {
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
onScrollToBottom()
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if !model.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture { onScrollToBottom() }
|
||||
.onTapGesture { scrollModel.scrollToBottom() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -888,56 +876,119 @@ struct ChatView: View {
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination = .initial(count: loadItemsPerPage)) {
|
||||
Task {
|
||||
if loadingItems { return }
|
||||
if case .before = pagination, firstPage { return }
|
||||
loadingItems = true
|
||||
|
||||
do {
|
||||
var reversedPage = Array<ChatItem>()
|
||||
var chatItemsAvailable = true
|
||||
// Load additional items until the page is +50 large after merging
|
||||
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
|
||||
let chatPagination: ChatPagination = switch pagination {
|
||||
case let .before(chatItemId, count): .before(chatItemId: reversedPage.last?.id ?? chatItemId, count: count)
|
||||
case let .last(count): .last(count: count)
|
||||
case let .initial(count): .initial(count: count)
|
||||
case let .after(chatItemId, count):
|
||||
.after(chatItemId: reversedPage.first?.id ?? chatItemId, count: count)
|
||||
case .around(_, _): throw RuntimeError("Unsupported pagination type for loading chat items: \(pagination)")
|
||||
let (chatItems, gap) = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: pagination,
|
||||
search: searchText
|
||||
)
|
||||
|
||||
if (cInfo.id != chatModel.chatId) {
|
||||
await MainActor.run { loadingItems = false }
|
||||
return
|
||||
}
|
||||
|
||||
let im = ItemsModel.shared
|
||||
var newItems = im.reversedChatItems
|
||||
|
||||
switch pagination {
|
||||
case .last:
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chatItems.reversed()
|
||||
im.gaps = []
|
||||
loadingItems = false
|
||||
}
|
||||
case let .initial(count):
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chatItems.reversed()
|
||||
|
||||
if gap != nil {
|
||||
let firstBottomItem = chatItems[count]
|
||||
im.gaps = [firstBottomItem.id]
|
||||
} else {
|
||||
im.gaps = []
|
||||
}
|
||||
loadingItems = false
|
||||
}
|
||||
case let .after(chatItemId, _):
|
||||
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let chatItems = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: chatPagination,
|
||||
search: searchText
|
||||
)
|
||||
chatItemsAvailable = !chatItems.isEmpty
|
||||
reversedPage.append(contentsOf: chatItems.reversed())
|
||||
}
|
||||
let dedupedreversePage = reversedPage.filter { !im.chatItemIds.contains($0.id) }
|
||||
|
||||
await MainActor.run {
|
||||
if reversedPage.count == 0 {
|
||||
firstPage = true
|
||||
} else if dedupedreversePage.count > 0 {
|
||||
switch pagination {
|
||||
case .last, .initial:
|
||||
im.reversedChatItems.append(contentsOf: dedupedreversePage)
|
||||
case .before(_, _):
|
||||
if im.reversedChatItems.count + dedupedreversePage.count > idealChatListSize,
|
||||
dedupedreversePage.count <= loadItemsPerPage {
|
||||
im.reversedChatItems.removeSubrange(loadItemsPerPage..<loadItemsPerPage + dedupedreversePage.count)
|
||||
}
|
||||
im.reversedChatItems.append(contentsOf: dedupedreversePage)
|
||||
case let .after(chatItemId, _):
|
||||
let index = im.reversedChatItems.firstIndex { $0.id == chatItemId }
|
||||
if let index {
|
||||
im.reversedChatItems.insert(contentsOf: dedupedreversePage, at: index)
|
||||
}
|
||||
case .around(_, _): break
|
||||
}
|
||||
let wasSize = newItems.count
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
let indexInGaps = im.gaps.firstIndex { $0 == chatItemId }
|
||||
var gapsAfterChatItem: [Int64] = []
|
||||
if let indexInGaps = indexInGaps, indexInGaps + 1 <= im.gaps.count {
|
||||
gapsAfterChatItem = Array(im.gaps[indexInGaps + 1..<im.gaps.count])
|
||||
}
|
||||
var gapsToRemove = Set<Int64>()
|
||||
var reachedBottom: Bool = false
|
||||
|
||||
newItems.removeAll { item in
|
||||
let isDuplicate = newItemIds.contains(item.id)
|
||||
if indexInGaps != nil && newItemIds.contains(item.id) {
|
||||
if gapsAfterChatItem.contains(item.id) {
|
||||
gapsAfterChatItem.removeAll { $0 == item.id }
|
||||
gapsToRemove.insert(item.id)
|
||||
} else if reachedBottom == false && gapsAfterChatItem.isEmpty {
|
||||
// We passed all gaps and found a duplicated item below all of them, indicating no more gaps below the loaded items.
|
||||
reachedBottom = true
|
||||
}
|
||||
}
|
||||
return isDuplicate
|
||||
}
|
||||
|
||||
let insertAt = indexInCurrentItems - (wasSize - newItems.count)
|
||||
newItems.insert(contentsOf: chatItems.reversed(), at: insertAt)
|
||||
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = newItems
|
||||
var newGaps = im.gaps.filter { !gapsToRemove.contains($0) }
|
||||
|
||||
if reachedBottom {
|
||||
newGaps = []
|
||||
} else {
|
||||
if let enlargedGapIndex = im.gaps.firstIndex(where: { $0 == chatItemId }) {
|
||||
// Move the gap to the end of the loaded items.
|
||||
newGaps[enlargedGapIndex] = chatItems.last?.id ?? newGaps[enlargedGapIndex]
|
||||
}
|
||||
}
|
||||
|
||||
im.gaps = newGaps
|
||||
loadingItems = false
|
||||
}
|
||||
case let .before(chatItemId, _):
|
||||
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
|
||||
return
|
||||
}
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
newItems.removeAll { newItemIds.contains($0.id) }
|
||||
|
||||
newItems.insert(contentsOf: chatItems.reversed(), at: min(indexInCurrentItems + 1, newItems.count))
|
||||
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = newItems
|
||||
im.gaps = im.gaps.filter { !newItemIds.contains($0) }
|
||||
loadingItems = false
|
||||
}
|
||||
case .around(_, _):
|
||||
let newItemIds = Set(chatItems.map { $0.id })
|
||||
newItems.removeAll { newItemIds.contains($0.id) }
|
||||
newItems.insert(contentsOf: chatItems, at: 0)
|
||||
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = newItems
|
||||
if let lastItemId = chatItems.last?.id {
|
||||
im.gaps.insert(lastItemId, at: 0)
|
||||
}
|
||||
loadingItems = false
|
||||
}
|
||||
loadingItems = false
|
||||
}
|
||||
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
await MainActor.run { loadingItems = false }
|
||||
@@ -1336,7 +1387,7 @@ struct ChatView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> some View {
|
||||
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed, !ci.isPlaceholder {
|
||||
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
|
||||
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
|
||||
availableReactions.count > 0 {
|
||||
reactionsGroup
|
||||
|
||||
@@ -340,7 +340,7 @@ struct GroupMemberInfoView: View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
Task {
|
||||
do {
|
||||
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||
let (chat, _) = try await apiGetChat(type: .direct, id: contactId)
|
||||
chatModel.addChat(chat)
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
|
||||
@@ -222,13 +222,12 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
let originalSize = tableView.numberOfRows(inSection: 0)
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
|
||||
let countDiff = max(0, items.count - originalSize)
|
||||
if tableView.contentOffset.y == 100, originalSize < items.count, originalSize > 0 {
|
||||
let countDiff = max(0, items.count - itemCount)
|
||||
if tableView.contentOffset.y == 100, itemCount < items.count, itemCount > 0 {
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: false
|
||||
|
||||
@@ -119,7 +119,7 @@ struct SelectedItemsBottomToolbar: View {
|
||||
dee = dee && ci.meta.deletable && !ci.localNote
|
||||
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
|
||||
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
|
||||
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isPlaceholder
|
||||
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
|
||||
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
|
||||
return (de, dee, me, onlyOwnGroupItems, fe, sel)
|
||||
} else {
|
||||
|
||||
@@ -2339,7 +2339,6 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
|
||||
public var viewTimestamp = Date.now
|
||||
public var isLiveDummy: Bool = false
|
||||
public var isPlaceholder: Bool = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatDir, meta, content, formattedText, quotedItem, reactions, file
|
||||
@@ -2468,7 +2467,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
}
|
||||
|
||||
public var allowAddReaction: Bool {
|
||||
meta.itemDeleted == nil && !isLiveDummy && !isPlaceholder && reactions.filter({ $0.userReacted }).count < 3
|
||||
meta.itemDeleted == nil && !isLiveDummy && reactions.filter({ $0.userReacted }).count < 3
|
||||
}
|
||||
|
||||
public func autoReceiveFile() -> CIFile? {
|
||||
@@ -2664,30 +2663,6 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
item.isLiveDummy = true
|
||||
return item
|
||||
}
|
||||
|
||||
public static func placeholder(idx: Int, text: String, chatDir: CIDirection) -> ChatItem {
|
||||
var item = ChatItem(
|
||||
chatDir: chatDir,
|
||||
meta: CIMeta(
|
||||
itemId: Int64(idx * -10),
|
||||
itemTs: .now,
|
||||
itemText: text,
|
||||
itemStatus: .rcvRead,
|
||||
createdAt: .now,
|
||||
updatedAt: .now,
|
||||
itemDeleted: nil,
|
||||
itemEdited: false,
|
||||
itemLive: false,
|
||||
deletable: false,
|
||||
editable: false
|
||||
),
|
||||
content: .sndMsgContent(msgContent: .text(text)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
item.isPlaceholder = true
|
||||
return item
|
||||
}
|
||||
|
||||
public static func invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String) -> ChatItem {
|
||||
ChatItem(
|
||||
|
||||
Reference in New Issue
Block a user