From 3776e1c29c536fa445796dc91a9fc18c244706db Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 15 Aug 2022 21:07:11 +0100 Subject: [PATCH] ios: chat pagination (#910) * ios: chat pagination * pagination hack * rotationEffect * more rotation * the least broken context menu * custom contect menu * add context item menus * fix context menu preview size * fix content menu targeted previews * subclass context menu view * remove UIView subclass * move coordinator class inside view * context menu and clicks work * reverse model * update item view based on viewId * hide underlying swiftui item * cover swiftui item with solid color * remove overlay * move hostview to async block * background overlay * remove async hostview * clear chat items on back buttom * update viewId on status changes --- apps/ios/Shared/Model/ChatModel.swift | 35 +-- apps/ios/Shared/Model/SimpleXAPI.swift | 17 +- apps/ios/Shared/Views/Chat/ChatView.swift | 270 ++++++++++++------ .../Shared/Views/Helpers/ContextMenu.swift | 88 ++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 21 ++ .../xcshareddata/swiftpm/Package.resolved | 9 + apps/ios/SimpleXChat/ChatTypes.swift | 27 +- 7 files changed, 348 insertions(+), 119 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/ContextMenu.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index b0cecea3ec..175e9c387e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -22,7 +22,7 @@ final class ChatModel: ObservableObject { @Published var chats: [Chat] = [] // current chat @Published var chatId: String? - @Published var chatItems: [ChatItem] = [] + @Published var reversedChatItems: [ChatItem] = [] @Published var chatToTop: String? @Published var groupMembers: [GroupMember] = [] // items in the terminal view @@ -159,7 +159,7 @@ final class ChatModel: ObservableObject { } // add to current chat if chatId == cInfo.id { - withAnimation { chatItems.append(cItem) } + withAnimation { reversedChatItems.insert(cItem, at: 0) } if case .rcvNew = cItem.meta.itemStatus { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if self.chatId == cInfo.id { @@ -187,13 +187,14 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == cInfo.id { - if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { + if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { withAnimation(.default) { - self.chatItems[i] = cItem + self.reversedChatItems[i] = cItem + self.reversedChatItems[i].viewTimestamp = .now } return false } else { - withAnimation { chatItems.append(cItem) } + withAnimation { reversedChatItems.insert(cItem, at: 0) } return true } } else { @@ -210,12 +211,12 @@ final class ChatModel: ObservableObject { } // remove from current chat if chatId == cInfo.id { - if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { - if chatItems[i].isRcvNew() == true { + if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + if reversedChatItems[i].isRcvNew() == true { NtfManager.shared.decNtfBadgeCount() } _ = withAnimation { - self.chatItems.remove(at: i) + self.reversedChatItems.remove(at: i) } } } @@ -230,9 +231,10 @@ final class ChatModel: ObservableObject { // update current chat if chatId == cInfo.id { var i = 0 - while i < chatItems.count { - if case .rcvNew = chatItems[i].meta.itemStatus { - chatItems[i].meta.itemStatus = .rcvRead + while i < reversedChatItems.count { + if case .rcvNew = reversedChatItems[i].meta.itemStatus { + reversedChatItems[i].meta.itemStatus = .rcvRead + reversedChatItems[i].viewTimestamp = .now } i = i + 1 } @@ -249,7 +251,7 @@ final class ChatModel: ObservableObject { } // clear current chat if chatId == cInfo.id { - chatItems = [] + reversedChatItems = [] } } @@ -259,8 +261,9 @@ final class ChatModel: ObservableObject { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1 } // update current chat - if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) { - chatItems[j].meta.itemStatus = .rcvRead + if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + reversedChatItems[j].meta.itemStatus = .rcvRead + reversedChatItems[j].viewTimestamp = .now } } @@ -269,8 +272,8 @@ final class ChatModel: ObservableObject { } func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { - if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 { - return chatItems[i - 1] + if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 { + return reversedChatItems[i + 1] } else { return nil } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d7dd4c7b9d..0bc3f9a5ee 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -185,19 +185,25 @@ func apiGetChats() throws -> [ChatData] { throw r } -func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination = .last(count: 100)) throws -> Chat { - let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: pagination)) +func apiGetChat(type: ChatType, id: Int64) throws -> Chat { + let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50))) if case let .apiChat(chat) = r { return Chat.init(chat) } throw r } +func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination) async throws -> [ChatItem] { + let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination)) + if case let .apiChat(chat) = r { return chat.chatItems } + throw r +} + func loadChat(chat: Chat) { do { let cInfo = chat.chatInfo let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId) let m = ChatModel.shared m.updateChatInfo(chat.chatInfo) - m.chatItems = chat.chatItems + m.reversedChatItems = chat.chatItems.reversed() } catch let error { logger.error("loadChat error: \(responseError(error))") } @@ -548,10 +554,11 @@ func markChatRead(_ chat: Chat) async { func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { do { + logger.debug("apiMarkChatItemRead: \(cItem.id)") try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) - DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } + await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) } } catch { - logger.error("markChatItemRead apiChatRead error: \(responseError(error))") + logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))") } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 205336dbb4..5522be3c44 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -8,6 +8,7 @@ import SwiftUI import SimpleXChat +import Introspect private let memberImageSize: CGFloat = 34 @@ -22,6 +23,10 @@ struct ChatView: View { @FocusState private var keyboardVisible: Bool @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? + @State private var tableView: UITableView? + @State private var loadingItems = false + @State private var firstPage = false + @State private var scrolledToUnread = false var body: some View { let cInfo = chat.chatInfo @@ -35,47 +40,34 @@ struct ChatView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 5) { - ForEach(chatModel.chatItems) { ci in - if case let .groupRcv(member) = ci.chatDir { - let prevItem = chatModel.getPrevChatItem(ci) - HStack(alignment: .top, spacing: 0) { - let showMember = prevItem == nil || showMemberImage(member, prevItem) - if showMember { - ProfileImage(imageStr: member.memberProfile.image) - .frame(width: memberImageSize, height: memberImageSize) - } else { - Rectangle().fill(.clear) - .frame(width: memberImageSize, height: memberImageSize) - } - chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8) - } - .padding(.trailing) - .padding(.leading, 12) - } else { - chatItemWithMenu(ci, maxWidth).padding(.horizontal) - } + ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in + chatItemView(ci, maxWidth) + .scaleEffect(x: 1, y: -1, anchor: .center) + .onAppear { loadChatItems(cInfo, ci, proxy) } } - .onAppear { - DispatchQueue.main.async { - scrollToFirstUnread(proxy) - } - markAllRead() - } - .onChange(of: chatModel.chatItems.last?.id) { _ in - scrollToBottom(proxy) - } - .onChange(of: keyboardVisible) { _ in - if keyboardVisible { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - scrollToBottom(proxy, animation: .easeInOut(duration: 1)) - } - } + } + } + .onAppear { + DispatchQueue.main.async { + scrollToFirstUnread(proxy) + scrolledToUnread = true + } + markAllRead() + } + .onChange(of: chatModel.reversedChatItems.first?.id) { _ in + scrollToBottom(proxy) + } + .onChange(of: keyboardVisible) { _ in + if keyboardVisible { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + scrollToBottom(proxy, animation: .easeInOut(duration: 1)) } } } .onTapGesture { hideKeyboard() } } } + .scaleEffect(x: 1, y: -1, anchor: .center) Spacer(minLength: 0) @@ -86,11 +78,19 @@ struct ChatView: View { ) .disabled(!chat.chatInfo.sendMsgEnabled) } + .padding(.top, 1) .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button { chatModel.chatId = nil } label: { + Button { + chatModel.chatId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + if chatModel.chatId == nil { + chatModel.reversedChatItems = [] + } + } + } label: { HStack(spacing: 4) { Image(systemName: "chevron.backward") Text("Chats", comment: "back button to return to chats list") @@ -178,76 +178,156 @@ struct ChatView: View { } } - private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { - let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth) - .contextMenu { - if ci.isMsgContent() { - Button { - withAnimation { - if composeState.editing() { - composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) - } else { - composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) - } - } - } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } - Button { - var shareItems: [Any] = [ci.content.text] - if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { - shareItems.append(image) - } - showShareSheet(items: shareItems) - } label: { Label("Share", systemImage: "square.and.arrow.up") } - Button { - if case let .image(text, _) = ci.content.msgContent, - text == "", - let image = getLoadedImage(ci.file) { - UIPasteboard.general.image = image + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { + if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { + if loadingItems || firstPage || !scrolledToUnread { return } + loadingItems = true + Task { + do { + let items = try await apiGetChatItems( + type: cInfo.chatType, + id: cInfo.apiId, + pagination: .before(chatItemId: firstItem.id, count: 50) + ) + await MainActor.run { + if items.count == 0 { + firstPage = true } else { - UIPasteboard.general.string = ci.content.text + chatModel.reversedChatItems.append(contentsOf: items.reversed()) } - } label: { Label("Copy", systemImage: "doc.on.doc") } - if case .image = ci.content.msgContent, - let image = getLoadedImage(ci.file) { - Button { - UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) - } label: { Label("Save", systemImage: "square.and.arrow.down") } + loadingItems = false } - if ci.meta.editable { - Button { - withAnimation { - composeState = ComposeState(editingItem: ci) - } - } label: { Label("Edit", systemImage: "square.and.pencil") } - } - Button(role: .destructive) { - showDeleteMessage = true - deletingItem = ci - } label: { Label("Delete", systemImage: "trash") } - } else if ci.isDeletedContent() { - Button(role: .destructive) { - showDeleteMessage = true - deletingItem = ci - } label: { Label("Delete", systemImage: "trash") } + } catch let error { + logger.error("apiGetChat error: \(responseError(error))") + await MainActor.run { loadingItems = false } } } + } + } + + @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { + if case let .groupRcv(member) = ci.chatDir { + let prevItem = chatModel.getPrevChatItem(ci) + HStack(alignment: .top, spacing: 0) { + let showMember = prevItem == nil || showMemberImage(member, prevItem) + if showMember { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + } else { + Rectangle().fill(.clear) + .frame(width: memberImageSize, height: memberImageSize) + } + chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8) + } + .padding(.trailing) + .padding(.leading, 12) + } else { + chatItemWithMenu(ci, maxWidth).padding(.horizontal) + } + } + + private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { + let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading + var menu: [UIAction] = [] + if ci.isMsgContent() { + menu.append(contentsOf: [ + UIAction( + title: NSLocalizedString("Reply", comment: "chat item action"), + image: UIImage(systemName: "arrowshape.turn.up.left") + ) { _ in + withAnimation { + if composeState.editing() { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } + } + }, + UIAction( + title: NSLocalizedString("Share", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.up") + ) { _ in + var shareItems: [Any] = [ci.content.text] + if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { + shareItems.append(image) + } + showShareSheet(items: shareItems) + }, + UIAction( + title: NSLocalizedString("Copy", comment: "chat item action"), + image: UIImage(systemName: "doc.on.doc") + ) { _ in + if case let .image(text, _) = ci.content.msgContent, + text == "", + let image = getLoadedImage(ci.file) { + UIPasteboard.general.image = image + } else { + UIPasteboard.general.string = ci.content.text + } + } + ]) + if case .image = ci.content.msgContent, + let image = getLoadedImage(ci.file) { + menu.append( + UIAction( + title: NSLocalizedString("Save", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.down") + ) { _ in + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + ) + } + if ci.meta.editable { + menu.append( + UIAction( + title: NSLocalizedString("Edit", comment: "chat item action"), + image: UIImage(systemName: "square.and.pencil") + ) { _ in + withAnimation { + composeState = ComposeState(editingItem: ci) + } + } + ) + } + menu.append( + UIAction( + title: NSLocalizedString("Delete", comment: "chat item action"), + image: UIImage(systemName: "trash"), + attributes: [.destructive] + ) { _ in + showDeleteMessage = true + deletingItem = ci + } + ) + } else if ci.isDeletedContent() { + menu.append( + UIAction( + title: NSLocalizedString("Delete", comment: "chat item action"), + image: UIImage(systemName: "trash"), + attributes: [.destructive] + ) { _ in + showDeleteMessage = true + deletingItem = ci + } + ) + } + + return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth) + .uiKitContextMenu(actions: menu) .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) } - if let di = deletingItem { - if di.meta.editable { - Button("Delete for everyone",role: .destructive) { - deleteMessage(.cidmBroadcast) - } + if let di = deletingItem, di.meta.editable { + Button("Delete for everyone",role: .destructive) { + deleteMessage(.cidmBroadcast) } } } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) } - + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { switch (prevItem?.chatDir) { case .groupSnd: return true @@ -261,15 +341,15 @@ struct ChatView: View { } func scrollToBottom_(_ proxy: ScrollViewProxy) { - if let id = chatModel.chatItems.last?.id { - proxy.scrollTo(id, anchor: .bottom) + if let id = chatModel.reversedChatItems.first?.id { + proxy.scrollTo(id, anchor: .top) } } // align first unread with the top or the last unread with bottom func scrollToFirstUnread(_ proxy: ScrollViewProxy) { - if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) { - proxy.scrollTo(cItem.id) + if let cItem = chatModel.reversedChatItems.last(where: { $0.isRcvNew() }) { + proxy.scrollTo(cItem.id, anchor: .bottom) } else { scrollToBottom_(proxy) } @@ -311,7 +391,7 @@ struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.chatId = "@1" - chatModel.chatItems = [ + chatModel.reversedChatItems = [ ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), diff --git a/apps/ios/Shared/Views/Helpers/ContextMenu.swift b/apps/ios/Shared/Views/Helpers/ContextMenu.swift new file mode 100644 index 0000000000..e2342c274b --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ContextMenu.swift @@ -0,0 +1,88 @@ +// +// ContextMenu2.swift +// SimpleX (iOS) +// +// Created by Evgeny on 09/08/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UIKit +import SwiftUI + +extension View { + func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View { + self.overlay(Color(uiColor: .systemBackground)) + .overlay( + InteractionView(content: self, menu: UIMenu(title: title, children: actions)) + ) + } +} + +private struct InteractionConfig { + let content: Content + let menu: UIMenu +} + +private struct InteractionView: UIViewRepresentable { + let content: Content + let menu: UIMenu + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + let hostView = UIHostingController(rootView: content) + hostView.view.translatesAutoresizingMaskIntoConstraints = false + let constraints = [ + hostView.view.topAnchor.constraint(equalTo: view.topAnchor), + hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor), + hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor) + ] + view.addSubview(hostView.view) + view.addConstraints(constraints) + let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator) + view.addInteraction(menuInteraction) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIContextMenuInteractionDelegate { + let parent: InteractionView + + init(_ parent: InteractionView) { + self.parent = parent + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration( + identifier: nil, + previewProvider: nil, + actionProvider: { [weak self] _ in + guard let self = self else { return nil } + return self.parent.menu + } + ) + } + + // func contextMenuInteraction( + // _ interaction: UIContextMenuInteraction, + // willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, + // animator: UIContextMenuInteractionCommitAnimating + // ) { + // animator.addCompletion { + // print("user tapped") + // } + // } + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 254da99fd9..8659b991bf 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; + 5C00164028A1B87B0094D739 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 5C00163F28A1B87B0094D739 /* Introspect */; }; + 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; }; @@ -194,6 +196,7 @@ 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; + 5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; 5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = ""; }; @@ -323,6 +326,7 @@ buildActionMask = 2147483647; files = ( 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */, + 5C00164028A1B87B0094D739 /* Introspect in Frameworks */, 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, ); @@ -452,6 +456,7 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */, 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */, 5C6BA666289BD954009B8ECC /* DismissSheets.swift */, + 5C00164328A26FBC0094D739 /* ContextMenu.swift */, ); path = Helpers; sourceTree = ""; @@ -685,6 +690,7 @@ name = "SimpleX (iOS)"; packageProductDependencies = ( 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */, + 5C00163F28A1B87B0094D739 /* Introspect */, ); productName = "SimpleX (iOS)"; productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; @@ -785,6 +791,7 @@ mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */, + 5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = 5CA059CB279559F40002BEB4 /* Products */; projectDirPath = ""; @@ -861,6 +868,7 @@ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, @@ -1475,6 +1483,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.4; + }; + }; 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/twostraws/CodeScanner"; @@ -1486,6 +1502,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 5C00163F28A1B87B0094D739 /* Introspect */ = { + isa = XCSwiftPackageProductDependency; + package = 5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = Introspect; + }; 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */ = { isa = XCSwiftPackageProductDependency; package = 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3f1d1790ff..ded924d1c1 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,6 +8,15 @@ "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", "version" : "2.1.1" } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "f2616860a41f9d9932da412a8978fec79c06fe24", + "version" : "0.1.4" + } } ], "version" : 2 diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index fcb886bf85..d51e8f911d 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -710,7 +710,7 @@ public struct ChatItem: Identifiable, Decodable { self.quotedItem = quotedItem self.file = file } - + public var chatDir: CIDirection public var meta: CIMeta public var content: CIContent @@ -718,9 +718,17 @@ public struct ChatItem: Identifiable, Decodable { public var quotedItem: CIQuote? public var file: CIFile? - public var id: Int64 { get { meta.itemId } } + public var viewTimestamp = Date.now - public var timestampText: Text { get { meta.timestampText } } + private enum CodingKeys: String, CodingKey { + case chatDir, meta, content, formattedText, quotedItem, file + } + + public var id: Int64 { meta.itemId } + + public var viewId: String { "\(meta.itemId) \(viewTimestamp.timeIntervalSince1970)" } + + public var timestampText: Text { meta.timestampText } public var text: String { get { @@ -855,6 +863,7 @@ public struct CIMeta: Decodable { var itemText: String public var itemStatus: CIStatus var createdAt: Date + var updatedAt: Date public var itemDeleted: Bool public var itemEdited: Bool public var editable: Bool @@ -868,6 +877,7 @@ public struct CIMeta: Decodable { itemText: text, itemStatus: status, createdAt: ts, + updatedAt: ts, itemDeleted: itemDeleted, itemEdited: itemEdited, editable: editable @@ -892,6 +902,17 @@ public enum CIStatus: Decodable { case sndError(agentError: AgentErrorType) case rcvNew case rcvRead + + var id: String { + switch self { + case .sndNew: return "sndNew" + case .sndSent: return "sndSent" + case .sndErrorAuth: return "sndErrorAuth" + case .sndError: return "sndError" + case .rcvNew: return "rcvNew" + case .rcvRead: return "rcvRead" + } + } } public enum CIDeleteMode: String, Decodable {