mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-25 22:54:29 +00:00
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:
committed by
GitHub
parent
f9be6e6434
commit
4c8bc19182
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user