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
This commit is contained in:
Evgeny Poberezkin
2022-08-15 21:07:11 +01:00
committed by GitHub
parent 2e4ffb7fe9
commit 3776e1c29c
7 changed files with 348 additions and 119 deletions
+19 -16
View File
@@ -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
}
+12 -5
View File
@@ -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))")
}
}
+175 -95
View File
@@ -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"),
@@ -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<Content: View> {
let content: Content
let menu: UIMenu
}
private struct InteractionView<Content: View>: 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<Content>
init(_ parent: InteractionView<Content>) {
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")
// }
// }
}
}
@@ -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 = "<group>"; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = "<group>"; };
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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" */;
@@ -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
+24 -3
View File
@@ -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 {