ios: send multiple images (#1188)

* ios: send multiple images

* multi-select works (TODO race conditions)

* send multiple images, progress indicator in compose view

* scroll between fullscreen images, scroll to quoted item

* add swipe animation

* fix model state when sending the image

* fix sending multiple images

* use MainActor

* improve scrolling

* faster scroll

* improve scroll animation

* fix model updates
This commit is contained in:
Evgeny Poberezkin
2022-10-10 10:40:30 +01:00
committed by GitHub
parent f9be6e6434
commit 4c8bc19182
13 changed files with 333 additions and 110 deletions
+36 -12
View File
@@ -178,7 +178,7 @@ final class ChatModel: ObservableObject {
}
// add to current chat
if chatId == cInfo.id {
withAnimation { reversedChatItems.insert(cItem, at: 0) }
_ = _upsertChatItem(cInfo, cItem)
}
}
@@ -186,7 +186,11 @@ final class ChatModel: ObservableObject {
// update previews
var res: Bool
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem]
}
} else {
chat.chatItems = [cItem]
}
res = false
@@ -195,19 +199,23 @@ final class ChatModel: ObservableObject {
res = true
}
// update current chat
if chatId == cInfo.id {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
withAnimation(.default) {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
}
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
let ci = reversedChatItems[i]
withAnimation(.default) {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
if case .sndNew = cItem.meta.itemStatus {
self.reversedChatItems[i].meta = ci.meta
}
return false
} else {
withAnimation { reversedChatItems.insert(cItem, at: 0) }
return true
}
return false
} else {
return res
withAnimation { reversedChatItems.insert(cItem, at: 0) }
return true
}
}
@@ -231,6 +239,22 @@ final class ChatModel: ObservableObject {
}
}
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
while i < reversedChatItems.count - 1 {
i += 1
if let res = map(reversedChatItems[i]) { return res }
}
} else {
while i > 0 {
i -= 1
if let res = map(reversedChatItems[i]) { return res }
}
}
return nil
}
func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview
if let chat = getChat(cInfo.id) {
@@ -11,41 +11,24 @@ import SimpleXChat
struct CIImageView: View {
@Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem
let image: String
let file: CIFile?
let maxWidth: CGFloat
@Binding var imgWidth: CGFloat?
@State var showFullScreenImage = false
@State var scrollProxy: ScrollViewProxy?
@State private var showFullScreenImage = false
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
ZoomableScrollView {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
}
}
}
.onTapGesture { showFullScreenImage = false }
.gesture(
DragGesture(minimumDistance: 80).onChanged { gesture in
let t = gesture.translation
if t.height > 60 && t.height > abs(t.width) {
showFullScreenImage = false
}
}
)
FullScreenImageView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
}
.onTapGesture { showFullScreenImage = true }
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
let uiImage = UIImage(data: data) {
imageView(uiImage)
.onTapGesture {
if let file = file {
@@ -84,7 +67,7 @@ struct CIImageView: View {
}
@ViewBuilder private func loadingIndicator() -> some View {
if let file = file {
if let file = chatItem.file {
switch file.fileStatus {
case .sndTransfer:
ProgressView()
@@ -15,11 +15,13 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1,
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@State var msgWidth: CGFloat = 0
@State var imgWidth: CGFloat? = nil
@State var metaColor = Color.secondary
@@ -30,6 +32,14 @@ struct FramedItemView: View {
VStack(alignment: .leading, spacing: 0) {
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
.onTapGesture {
if let proxy = scrollProxy,
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
proxy.scrollTo(ci.viewId, anchor: .bottom)
}
}
}
}
if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) {
@@ -45,7 +55,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, image):
CIImageView(image: image, file: chatItem.file, maxWidth: maxWidth, imgWidth: $imgWidth)
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
.overlay(DetermineWidth())
if text == "" {
Color.clear
@@ -0,0 +1,93 @@
//
// FullScreenImageView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 08/10/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct FullScreenImageView: View {
@EnvironmentObject var m: ChatModel
@State var chatItem: ChatItem
@State var image: UIImage
@Binding var showView: Bool
@State var scrollProxy: ScrollViewProxy?
@State private var showNext = false
@State private var nextImage: UIImage?
@State private var scrolling = false
@State private var offset: CGFloat = 0
@State private var nextOffset: CGFloat = 0
var body: some View {
GeometryReader(content: imageScrollView)
}
func imageScrollView(_ g: GeometryProxy) -> some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
if showNext, let nextImage = nextImage {
imageView(image).offset(x: offset)
imageView(nextImage).offset(x: offset + nextOffset)
} else {
ZoomableScrollView {
imageView(image)
}
}
}
.onTapGesture { showView = false }
.gesture(
DragGesture(minimumDistance: 80)
.onChanged { gesture in
let t = gesture.translation
let w = abs(t.width)
if t.height > 60 && t.height > w * 2 {
showView = false
if let proxy = scrollProxy {
proxy.scrollTo(chatItem.viewId)
}
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0
scrolling = true
if let item = m.nextChatItemData(chatItem.id, previous: previous, map: chatItemImage) {
var img: UIImage
(chatItem, img) = item
nextImage = img
let s = g.size.width
var toOffset: CGFloat
(toOffset, nextOffset) = previous ? (s, -s) : (-s, s)
showNext = true
withAnimation(.easeIn(duration: 0.2)) {
offset = toOffset
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
image = img
showNext = false
offset = 0
}
}
}
}
.onEnded { _ in scrolling = false }
)
}
private func imageView(_ img: UIImage) -> some View {
ZStack {
Color.black
Image(uiImage: img)
.resizable()
.scaledToFit()
}
}
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage)? {
if case .image = ci.content.msgContent,
let img = getLoadedImage(ci.file) {
return (ci, img)
}
return nil
}
}
@@ -14,6 +14,7 @@ struct ChatItemView: View {
var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
var body: some View {
switch chatItem.content {
@@ -35,7 +36,7 @@ struct ChatItemView: View {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth)
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
}
}
+1 -1
View File
@@ -487,7 +487,7 @@ struct ChatView: View {
)
}
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
.uiKitContextMenu(actions: menu)
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
@@ -11,18 +11,33 @@ import SimpleXChat
struct ComposeImageView: View {
@Environment(\.colorScheme) var colorScheme
let image: String
let images: [String]
let cancelImage: (() -> Void)
let cancelEnabled: Bool
var body: some View {
HStack(alignment: .center, spacing: 8) {
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 80, minHeight: 40, maxHeight: 60)
let imgs: [UIImage] = images.compactMap { image in
if let data = Data(base64Encoded: dropImagePrefix(image)) {
return UIImage(data: data)
}
return nil
}
if imgs.count == 0 {
ProgressView()
.padding(.leading, 12)
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60, alignment: .leading)
} else {
ScrollView(.horizontal) {
HStack {
ForEach(imgs, id: \.hash) { img in
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: 80, minHeight: 40, maxHeight: 60)
}
}
}
}
Spacer()
if cancelEnabled {
@@ -12,7 +12,7 @@ import SimpleXChat
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
case imagePreview(imagePreview: String)
case imagePreviews(imagePreviews: [String])
case filePreview(fileName: String)
}
@@ -26,7 +26,8 @@ struct ComposeState {
var message: String
var preview: ComposePreview
var contextItem: ComposeContextItem
var inProgress: Bool = false
var inProgress = false
var disabled = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
init(
@@ -66,7 +67,7 @@ struct ComposeState {
func sendEnabled() -> Bool {
switch preview {
case .imagePreview:
case .imagePreviews:
return true
case .filePreview:
return true
@@ -77,7 +78,7 @@ struct ComposeState {
func linkPreviewAllowed() -> Bool {
switch preview {
case .imagePreview:
case .imagePreviews:
return false
case .filePreview:
return false
@@ -104,7 +105,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
case let .link(_, preview: preview):
chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image: image):
chatItemPreview = .imagePreview(imagePreview: image)
chatItemPreview = .imagePreviews(imagePreviews: [image])
case .file:
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
default:
@@ -127,7 +128,7 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@State var chosenImage: UIImage? = nil
@State var chosenImages: [UIImage] = []
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
@@ -179,7 +180,7 @@ struct ComposeView: View {
}
if UIPasteboard.general.hasImages {
Button("Paste image") {
chosenImage = UIPasteboard.general.image
chosenImages = imageList(UIPasteboard.general.image)
}
}
Button("Choose file") {
@@ -189,20 +190,35 @@ struct ComposeView: View {
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
CameraImageListPicker(images: $chosenImages)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
showImagePicker = false
if itemsSelected {
DispatchQueue.main.async {
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: []))
}
}
}
}
.onChange(of: chosenImage) { image in
if let image = image,
let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) {
composeState = composeState.copy(preview: .imagePreview(imagePreview: imagePreview))
} else {
composeState = composeState.copy(preview: .noPreview)
.onChange(of: chosenImages) { images in
Task {
var imgs: [String] = []
for image in images {
if let img = resizeImageToStrSize(image, maxDataSize: 14000) {
imgs.append(img)
await MainActor.run {
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
}
}
}
if imgs.count == 0 {
await MainActor.run {
composeState = composeState.copy(preview: .noPreview)
}
}
}
}
.fileImporter(
@@ -242,12 +258,12 @@ struct ComposeView: View {
EmptyView()
case let .linkPreview(linkPreview: preview):
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
case let .imagePreview(imagePreview: img):
case let .imagePreviews(imagePreviews: images):
ComposeImageView(
image: img,
images: images,
cancelImage: {
composeState = composeState.copy(preview: .noPreview)
chosenImage = nil
chosenImages = []
},
cancelEnabled: !composeState.editing())
case let .filePreview(fileName: fileName):
@@ -288,62 +304,82 @@ struct ComposeView: View {
case let .editingItem(chatItem: ei):
if let oldMsgContent = ei.content.msgContent {
do {
await sending()
let mc = updateMsgContent(oldMsgContent)
await MainActor.run { clearState() }
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
msg: mc
)
DispatchQueue.main.async {
await MainActor.run {
clearState()
let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
}
} catch {
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
await MainActor.run {
composeState.disabled = false
composeState.inProgress = false
}
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
}
} else {
await MainActor.run { clearState() }
}
default:
var mc: MsgContent? = nil
var file: String? = nil
await sending()
var quoted: Int64? = nil
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
quoted = quotedItem.id
}
switch (composeState.preview) {
case .noPreview:
mc = .text(composeState.message)
await send(.text(composeState.message), quoted: quoted)
case .linkPreview:
mc = checkLinkPreview()
case let .imagePreview(imagePreview: image):
if let uiImage = chosenImage,
let savedFile = saveImage(uiImage) {
mc = .image(text: composeState.message, image: image)
file = savedFile
await send(checkLinkPreview(), quoted: quoted)
case let .imagePreviews(imagePreviews: images):
var text = composeState.message
var sent = false
for i in 0..<min(chosenImages.count, images.count) {
if i > 0 { _ = try? await Task.sleep(nanoseconds: 100_000000) }
if let savedFile = saveImage(chosenImages[i]) {
await send(.image(text: text, image: images[i]), quoted: quoted, file: savedFile)
text = ""
quoted = nil
sent = true
}
}
if !sent {
await send(.text(composeState.message), quoted: quoted)
}
case .filePreview:
if let fileURL = chosenFile,
let savedFile = saveFileFromURL(fileURL) {
mc = .file(composeState.message)
file = savedFile
await send(.file(composeState.message), quoted: quoted, file: savedFile)
}
}
var quotedItemId: Int64? = nil
switch (composeState.contextItem) {
case let .quotedItem(chatItem: quotedItem):
quotedItemId = quotedItem.id
default:
quotedItemId = nil
}
await MainActor.run { clearState() }
if let mc = mc,
let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quotedItemId,
msg: mc
) {
}
await MainActor.run { clearState() }
}
func sending() async {
await MainActor.run { composeState.disabled = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if composeState.disabled { composeState.inProgress = true }
}
}
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil) async {
if let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc
) {
await MainActor.run {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
@@ -356,7 +392,7 @@ struct ComposeView: View {
prevLinkUrl = nil
pendingLinkUrl = nil
cancelledLinks = []
chosenImage = nil
chosenImages = []
chosenFile = nil
}
@@ -54,7 +54,7 @@ struct SendMessageView: View {
.resizable()
.foregroundColor(.accentColor)
}
.disabled(!composeState.sendEnabled())
.disabled(!composeState.sendEnabled() || composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
+68 -11
View File
@@ -9,15 +9,30 @@
import SwiftUI
import PhotosUI
struct LibraryImagePicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController
struct LibraryImagePicker: View {
@Binding var image: UIImage?
var didFinishPicking: (_ didSelectItems: Bool) -> Void
@State var images: [UIImage] = []
var body: some View {
LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
.onChange(of: images) { image = $0.first }
}
}
struct LibraryImageListPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController
@Binding var images: [UIImage]
var selectionLimit: Int
var didFinishPicking: (_ didSelectItems: Bool) -> Void
class Coordinator: PHPickerViewControllerDelegate {
let parent: LibraryImagePicker
let parent: LibraryImageListPicker
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker")
var images: [UIImage] = []
var imageCount: Int = 0
init(_ parent: LibraryImagePicker) {
init(_ parent: LibraryImageListPicker) {
self.parent = parent
}
@@ -27,22 +42,47 @@ struct LibraryImagePicker: UIViewControllerRepresentable {
return
}
if let chosenImageProvider = results.first?.itemProvider {
if chosenImageProvider.canLoadObject(ofClass: UIImage.self) {
chosenImageProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
parent.images = []
images = []
imageCount = results.count
for result in results {
logger.log("LibraryImageListPicker result")
let p = result.itemProvider
if p.canLoadObject(ofClass: UIImage.self) {
p.loadObject(ofClass: UIImage.self) { image, error in
DispatchQueue.main.async {
self?.loadImage(object: image, error: error)
self.loadImage(object: image, error: error)
}
}
} else {
dispatchQueue.sync { self.imageCount -= 1}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.dispatchQueue.sync {
if self.parent.images.count == 0 {
logger.log("LibraryImageListPicker: added \(self.images.count) images out of \(results.count)")
self.parent.images = self.images
}
}
}
}
func loadImage(object: Any?, error: Error? = nil) {
if let error = error {
logger.error("Couldn't load image with error: \(error.localizedDescription)")
logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)")
} else if let image = object as? UIImage {
images.append(image)
logger.log("LibraryImageListPicker: added image")
}
dispatchQueue.sync {
self.imageCount -= 1
if self.imageCount == 0 && self.parent.images.count == 0 {
logger.log("LibraryImageListPicker: added all images")
self.parent.images = self.images
self.images = []
}
}
parent.image = object as? UIImage
}
}
@@ -53,7 +93,7 @@ struct LibraryImagePicker: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
config.selectionLimit = selectionLimit
let controller = PHPickerViewController(configuration: config)
controller.delegate = context.coordinator
return controller
@@ -64,6 +104,23 @@ struct LibraryImagePicker: UIViewControllerRepresentable {
}
}
struct CameraImageListPicker: View {
@Binding var images: [UIImage]
@State var image: UIImage?
var body: some View {
CameraImagePicker(image: $image)
.onChange(of: image) { images = imageList($0) }
}
}
func imageList(_ img: UIImage?) -> [UIImage] {
if let img = img {
return [img]
} else {
return []
}
}
struct CameraImagePicker: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
@@ -16,7 +16,7 @@ struct NavLinkPlain<V: Hashable, Label: View>: View {
var body: some View {
ZStack {
Button("") { selection = tag }
Button("") { DispatchQueue.main.async { selection = tag } }
.disabled(disabled)
label()
}
@@ -19,6 +19,7 @@
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; };
5C10D88A28F187F300E58BF0 /* FullScreenImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */; };
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
@@ -209,6 +210,7 @@
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = "<group>"; };
5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenImageView.swift; sourceTree = "<group>"; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = "<group>"; };
5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
@@ -633,6 +635,7 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenImageView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
@@ -895,6 +898,7 @@
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C10D88A28F187F300E58BF0 /* FullScreenImageView.swift in Sources */,
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */,
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
+1 -1
View File
@@ -1119,7 +1119,7 @@ public enum CIContent: Decodable, ItemContent {
public struct CIQuote: Decodable, ItemContent {
var chatDir: CIDirection?
var itemId: Int64?
public var itemId: Int64?
var sharedMsgId: String? = nil
var sentAt: Date
public var content: MsgContent