Merge branch 'master' into webrtc-calls
This commit is contained in:
@@ -12,6 +12,8 @@ import SwiftUI
|
||||
// maximum image file size to be auto-accepted
|
||||
let maxImageSize = 236700
|
||||
|
||||
let maxFileSize = 1893600
|
||||
|
||||
func getDocumentsDirectory() -> URL {
|
||||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
}
|
||||
@@ -24,7 +26,7 @@ func getStoredFilePath(_ file: CIFile?) -> String? {
|
||||
if let file = file,
|
||||
file.stored,
|
||||
let savedFile = file.filePath {
|
||||
return getAppFilesDirectory().path + "/" + savedFile
|
||||
return getAppFilesDirectory().appendingPathComponent(savedFile).path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -35,3 +37,78 @@ func getStoredImage(_ file: CIFile?) -> UIImage? {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// image utils
|
||||
|
||||
func dropImagePrefix(_ s: String) -> String {
|
||||
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
|
||||
}
|
||||
|
||||
private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
|
||||
func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
let size = image.size
|
||||
let side = min(size.width, size.height)
|
||||
let newSize = CGSize(width: side, height: side)
|
||||
var origin = CGPoint.zero
|
||||
if size.width > side {
|
||||
origin.x -= (size.width - side) / 2
|
||||
} else if size.height > side {
|
||||
origin.y -= (size.height - side) / 2
|
||||
}
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
|
||||
}
|
||||
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
|
||||
var img = image
|
||||
var data = img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
data = img.jpegData(compressionQuality: 0.85)
|
||||
dataSize = data?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToDataSize final \(dataSize)")
|
||||
return data
|
||||
}
|
||||
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
var dataSize = str?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
str = compressImageStr(img)
|
||||
dataSize = str?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToStrSize final \(dataSize)")
|
||||
return str
|
||||
}
|
||||
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
|
||||
if let data = image.jpegData(compressionQuality: compressionQuality) {
|
||||
return "data:image/jpg;base64,\(data.base64EncodedString())"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
|
||||
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
|
||||
let bounds = CGRect(origin: .zero, size: newSize)
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds)
|
||||
}
|
||||
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
format.opaque = true
|
||||
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
||||
image.draw(in: drawIn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@ enum ChatResponse: Decodable, Error {
|
||||
case chatItemStatusUpdated(chatItem: AChatItem)
|
||||
case chatItemUpdated(chatItem: AChatItem)
|
||||
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
|
||||
case rcvFileAccepted
|
||||
case rcvFileAccepted(chatItem: AChatItem)
|
||||
case rcvFileStart(chatItem: AChatItem)
|
||||
case rcvFileComplete(chatItem: AChatItem)
|
||||
case ntfTokenStatus(status: NtfTknStatus)
|
||||
case newContactConnection(connection: PendingContactConnection)
|
||||
@@ -216,6 +217,7 @@ enum ChatResponse: Decodable, Error {
|
||||
case .chatItemUpdated: return "chatItemUpdated"
|
||||
case .chatItemDeleted: return "chatItemDeleted"
|
||||
case .rcvFileAccepted: return "rcvFileAccepted"
|
||||
case .rcvFileStart: return "rcvFileStart"
|
||||
case .rcvFileComplete: return "rcvFileComplete"
|
||||
case .ntfTokenStatus: return "ntfTokenStatus"
|
||||
case .newContactConnection: return "newContactConnection"
|
||||
@@ -266,7 +268,8 @@ enum ChatResponse: Decodable, Error {
|
||||
case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
|
||||
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
|
||||
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
|
||||
case .rcvFileAccepted: return noDetails
|
||||
case let .rcvFileAccepted(chatItem): return String(describing: chatItem)
|
||||
case let .rcvFileStart(chatItem): return String(describing: chatItem)
|
||||
case let .rcvFileComplete(chatItem): return String(describing: chatItem)
|
||||
case let .ntfTokenStatus(status): return String(describing: status)
|
||||
case let .newContactConnection(connection): return String(describing: connection)
|
||||
|
||||
@@ -480,6 +480,16 @@ struct ChatItem: Identifiable, Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false),
|
||||
content: .rcvMsgContent(msgContent: .file(text)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
|
||||
)
|
||||
}
|
||||
|
||||
static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: dir,
|
||||
@@ -629,7 +639,7 @@ struct CIFile: Decodable {
|
||||
var filePath: String?
|
||||
var fileStatus: CIFileStatus
|
||||
|
||||
static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile {
|
||||
static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile {
|
||||
CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
|
||||
}
|
||||
|
||||
@@ -649,6 +659,7 @@ enum CIFileStatus: String, Decodable {
|
||||
case sndStored = "snd_stored"
|
||||
case sndCancelled = "snd_cancelled"
|
||||
case rcvInvitation = "rcv_invitation"
|
||||
case rcvAccepted = "rcv_accepted"
|
||||
case rcvTransfer = "rcv_transfer"
|
||||
case rcvComplete = "rcv_complete"
|
||||
case rcvCancelled = "rcv_cancelled"
|
||||
@@ -658,6 +669,7 @@ enum MsgContent {
|
||||
case text(String)
|
||||
case link(text: String, preview: LinkPreview)
|
||||
case image(text: String, image: String)
|
||||
case file(String)
|
||||
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
|
||||
case unknown(type: String, text: String)
|
||||
|
||||
@@ -667,6 +679,7 @@ enum MsgContent {
|
||||
case let .text(text): return text
|
||||
case let .link(text, _): return text
|
||||
case let .image(text, _): return text
|
||||
case let .file(text): return text
|
||||
case let .unknown(_, text): return text
|
||||
}
|
||||
}
|
||||
@@ -680,6 +693,7 @@ enum MsgContent {
|
||||
return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}"
|
||||
case let .image(text: text, image: image):
|
||||
return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}"
|
||||
case let .file(text): return "json {\"type\":\"file\",\"text\":\(encodeJSON(text))}"
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
@@ -690,6 +704,7 @@ enum MsgContent {
|
||||
case text
|
||||
case preview
|
||||
case image
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +726,9 @@ extension MsgContent: Decodable {
|
||||
let text = try container.decode(String.self, forKey: CodingKeys.text)
|
||||
let image = try container.decode(String.self, forKey: CodingKeys.image)
|
||||
self = .image(text: text, image: image)
|
||||
case "file":
|
||||
let text = try container.decode(String.self, forKey: CodingKeys.text)
|
||||
self = .file(text)
|
||||
default:
|
||||
let text = try? container.decode(String.self, forKey: CodingKeys.text)
|
||||
self = .unknown(type: type, text: text ?? "unknown message format")
|
||||
|
||||
@@ -326,9 +326,18 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
}
|
||||
|
||||
func receiveFile(fileId: Int64) async throws {
|
||||
func receiveFile(fileId: Int64) async {
|
||||
do {
|
||||
let chatItem = try await apiReceiveFile(fileId: fileId)
|
||||
DispatchQueue.main.async { chatItemSimpleUpdate(chatItem) }
|
||||
} catch let error {
|
||||
logger.error("receiveFile error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func apiReceiveFile(fileId: Int64) async throws -> AChatItem {
|
||||
let r = await chatSendCmd(.receiveFile(fileId: fileId))
|
||||
if case .rcvFileAccepted = r { return }
|
||||
if case .rcvFileAccepted(let chatItem) = r { return chatItem }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -470,14 +479,11 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
m.addChatItem(cInfo, cItem)
|
||||
if let file = cItem.file,
|
||||
if case .image = cItem.content.msgContent,
|
||||
let file = cItem.file,
|
||||
file.fileSize <= maxImageSize {
|
||||
Task {
|
||||
do {
|
||||
try await receiveFile(fileId: file.fileId)
|
||||
} catch {
|
||||
logger.error("receiveFile error: \(error.localizedDescription)")
|
||||
}
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
@@ -499,11 +505,7 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
}
|
||||
}
|
||||
case let .chatItemUpdated(aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if m.upsertChatItem(cInfo, cItem) {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
case let .chatItemDeleted(_, toChatItem):
|
||||
let cInfo = toChatItem.chatInfo
|
||||
let cItem = toChatItem.chatItem
|
||||
@@ -513,18 +515,25 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
_ = m.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
case let .rcvFileStart(aChatItem):
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
case let .rcvFileComplete(aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if m.upsertChatItem(cInfo, cItem) {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chatItemSimpleUpdate(_ aChatItem: AChatItem) {
|
||||
let m = ChatModel.shared
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if m.upsertChatItem(cInfo, cItem) {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
|
||||
func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) {
|
||||
let m = ChatModel.shared
|
||||
for c in contactRefs {
|
||||
|
||||
@@ -55,6 +55,11 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .file(text):
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
if text != "" {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .link(_, preview):
|
||||
CILinkView(linkPreview: preview)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
|
||||
@@ -135,6 +135,12 @@ struct ChatView: View {
|
||||
UIPasteboard.general.string = ci.content.text
|
||||
}
|
||||
} label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = getStoredImage(ci.file) {
|
||||
Button {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
} label: { Label("Save", systemImage: "square.and.arrow.down") }
|
||||
}
|
||||
if ci.meta.editable {
|
||||
Button {
|
||||
withAnimation {
|
||||
|
||||
@@ -276,6 +276,8 @@ struct ComposeView: View {
|
||||
return checkLinkPreview()
|
||||
case .image(_, let image):
|
||||
return .image(text: composeState.message, image: image)
|
||||
case .file:
|
||||
return .file(composeState.message)
|
||||
case .unknown(let type, _):
|
||||
return .unknown(type: type, text: composeState.message)
|
||||
}
|
||||
|
||||
177
apps/ios/Shared/Views/Helpers/CIFileView.swift
Normal file
177
apps/ios/Shared/Views/Helpers/CIFileView.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
//
|
||||
// CIFileView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by JRoberts on 28/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CIFileView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let file: CIFile?
|
||||
let edited: Bool
|
||||
|
||||
var body: some View {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.primary)
|
||||
Text(formatBytes(bytes: file.fileSize) + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(file == nil || (file?.fileStatus != .rcvInvitation && file?.fileStatus != .rcvAccepted && file?.fileStatus != .rcvComplete))
|
||||
}
|
||||
|
||||
func fileSizeValid() -> Bool {
|
||||
if let file = file {
|
||||
return file.fileSize <= maxFileSize
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileAction() {
|
||||
logger.debug("CIFileView processFile")
|
||||
if let file = file {
|
||||
switch (file.fileStatus) {
|
||||
case .rcvInvitation:
|
||||
if fileSizeValid() {
|
||||
Task {
|
||||
logger.debug("CIFileView processFile - in .rcvInvitation, in Task")
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)."
|
||||
)
|
||||
}
|
||||
case .rcvAccepted:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for file",
|
||||
message: "File will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
case .rcvComplete:
|
||||
logger.debug("CIFileView processFile - in .rcvComplete")
|
||||
if let filePath = getStoredFilePath(file){
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [url])
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func fileIndicator() -> some View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvInvitation:
|
||||
if fileSizeValid() {
|
||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
||||
} else {
|
||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||
}
|
||||
case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
|
||||
case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
|
||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
default: fileIcon("doc.fill")
|
||||
}
|
||||
} else {
|
||||
fileIcon("doc.fill")
|
||||
}
|
||||
}
|
||||
|
||||
func fileIcon(_ icon: String, color: Color = .secondary, innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 30, height: 30)
|
||||
.foregroundColor(color)
|
||||
if let innerIcon = innerIcon,
|
||||
let innerIconSize = innerIconSize {
|
||||
Image(systemName: innerIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxHeight: 16)
|
||||
.frame(width: innerIconSize, height: innerIconSize)
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(bytes: Int64) -> String {
|
||||
if (bytes == 0) { return "0 bytes" }
|
||||
|
||||
let bytesDouble = Double(bytes)
|
||||
let k: Double = 1000
|
||||
let units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||
|
||||
let i = floor(log2(bytesDouble) / log2(k))
|
||||
let size = bytesDouble / pow(k, i)
|
||||
let unit = units[Int(i)]
|
||||
|
||||
if (i <= 1) {
|
||||
return String(format: "%.0f \(unit)", size)
|
||||
} else {
|
||||
return String(format: "%.2f \(unit)", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentFile = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||
content: .sndMsgContent(msgContent: .file("")),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndStored)
|
||||
)
|
||||
let fileChatItemWtFile = ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
|
||||
content: .rcvMsgContent(msgContent: .file("")),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
Group{
|
||||
ChatItemView(chatItem: sentFile)
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample())
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 2000000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: fileChatItemWtFile)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ struct CIImageView: View {
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
.onTapGesture {
|
||||
if case .rcvAccepted = file?.fileStatus {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for image",
|
||||
message: "Image will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +56,32 @@ struct CIImageView: View {
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
|
||||
DispatchQueue.main.async { imgWidth = w }
|
||||
return Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
loadingIndicator()
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func loadingIndicator() -> some View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvAccepted:
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(.white)
|
||||
case .rcvTransfer:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 20, height: 20)
|
||||
.tint(.white)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,80 +9,6 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
|
||||
func dropImagePrefix(_ s: String) -> String {
|
||||
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
|
||||
}
|
||||
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
format.opaque = true
|
||||
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
||||
image.draw(in: drawIn)
|
||||
}
|
||||
}
|
||||
|
||||
func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
let size = image.size
|
||||
let side = min(size.width, size.height)
|
||||
let newSize = CGSize(width: side, height: side)
|
||||
var origin = CGPoint.zero
|
||||
if size.width > side {
|
||||
origin.x -= (size.width - side) / 2
|
||||
} else if size.height > side {
|
||||
origin.y -= (size.height - side) / 2
|
||||
}
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
|
||||
}
|
||||
|
||||
|
||||
func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
|
||||
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
|
||||
let bounds = CGRect(origin: .zero, size: newSize)
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds)
|
||||
}
|
||||
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
var dataSize = str?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
str = compressImageStr(img)
|
||||
dataSize = str?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToStrSize final \(dataSize)")
|
||||
return str
|
||||
}
|
||||
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
|
||||
if let data = image.jpegData(compressionQuality: compressionQuality) {
|
||||
return "data:image/jpg;base64,\(data.base64EncodedString())"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
|
||||
var img = image
|
||||
var data = img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
data = img.jpegData(compressionQuality: 0.85)
|
||||
dataSize = data?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToDataSize final \(dataSize)")
|
||||
return data
|
||||
}
|
||||
|
||||
enum ImageSource {
|
||||
case imageLibrary
|
||||
case camera
|
||||
|
||||
Reference in New Issue
Block a user