mobile: correctly resize images, refine image sending UI (#546)

This commit is contained in:
JRoberts
2022-04-25 12:44:24 +04:00
committed by GitHub
parent 0470f9cf36
commit db4731f19b
21 changed files with 485 additions and 326 deletions

View File

@@ -371,7 +371,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
val file = cItem.file
if (file != null && file.fileSize <= 236700) { // 394500
if (file != null && file.fileSize <= MAX_IMAGE_SIZE) {
withApi {receiveFile(file.fileId)}
}
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {

View File

@@ -87,13 +87,16 @@ fun ChatView(chatModel: ChatModel) {
val cInfo = chat.chatInfo
val ei = editingItem.value
if (ei != null) {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = MsgContent.MCText(msg)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent, msg)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
}
} else {
var file: String? = null
val imagePreviewData = imagePreview.value
@@ -142,17 +145,26 @@ fun ChatView(chatModel: ChatModel) {
}
},
parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } },
onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) }
onImageChange = { bitmap -> imagePreview.value = resizeImageToStrSize(bitmap, maxDataSize = 14000) }
)
}
}
fun updateMsgContent(msgContent: MsgContent, text: String): MsgContent {
return when (msgContent) {
is MsgContent.MCText -> MsgContent.MCText(text)
is MsgContent.MCLink -> MsgContent.MCLink(text, preview = msgContent.preview)
is MsgContent.MCImage -> MsgContent.MCImage(text, image = msgContent.image)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = text, json = msgContent.json)
}
}
fun saveImage(context: Context, image: Bitmap): String {
val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000))
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
val fileToSave = "image_${System.currentTimeMillis()}.jpg"
val file = File(getAppFilesDirectory(context) + "/" + fileToSave)
val output = FileOutputStream(file)
imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output)
dataResized.writeTo(output)
output.flush()
output.close()
return fileToSave
@@ -205,7 +217,7 @@ fun ChatLayout(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = {
ComposeView(
msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown,
msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, sendMessage, resetMessage, parseMarkdown,
showBottomSheet = { scope.launch { bottomSheetModalState.show() } }
)
},

View File

@@ -1,16 +1,22 @@
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.base64ToBitmap
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun ComposeImageView(image: String) {
fun ComposeImageView(image: String, cancelImage: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
@@ -27,5 +33,14 @@ fun ComposeImageView(image: String) {
.height(60.dp)
.padding(end = 8.dp)
)
Spacer(Modifier.weight(1f))
IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Outlined.Close,
contentDescription = generalGetString(R.string.icon_descr_cancel_image_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}

View File

@@ -1,22 +1,34 @@
package chat.simplex.app.views.chat
import ComposeImageView
import android.graphics.Bitmap
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.ComposeLinkView
import chat.simplex.app.views.helpers.generalGetString
// TODO ComposeState
@Composable
fun ComposeView(
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
chosenImage: MutableState<Bitmap?>,
imagePreview: MutableState<String?>,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
@@ -33,10 +45,15 @@ fun ComposeView(
linkPreview.value = null
}
fun cancelImage() {
chosenImage.value = null
imagePreview.value = null
}
Column {
val ip = imagePreview.value
if (ip != null) {
ComposeImageView(ip)
ComposeImageView(ip, ::cancelImage)
} else {
val lp = linkPreview.value
if (lp != null) ComposeLinkView(lp, ::cancelPreview)
@@ -51,28 +68,29 @@ fun ComposeView(
else -> {}
}
Row(
modifier = Modifier.padding(horizontal = 8.dp),
// // use this padding when attach button is uncommented
// modifier = Modifier.padding(start = 2.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
// Icon(
// Icons.Outlined.AddCircleOutline,
// contentDescription = generalGetString(R.string.attach),
// tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray,
// modifier = Modifier
// .size(40.dp)
// .padding(vertical = 4.dp)
// .clip(CircleShape)
// .clickable {
// if (editingItem.value == null) {
// showBottomSheet()
// }
// }
// )
SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage,
editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null)
Box(Modifier.padding(bottom = 12.dp)) {
Icon(
Icons.Filled.AttachFile,
contentDescription = generalGetString(R.string.attach),
tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable {
if (editingItem.value == null) {
showBottomSheet()
}
}
)
}
SendMsgView(
msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage,
editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null
)
}
}
}

View File

@@ -49,24 +49,42 @@ fun cropToSquare(image: Bitmap): Bitmap {
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
}
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String {
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Int): String {
var img = image
var str = compressImage(img)
var str = compressImageStr(img)
while (str.length > maxDataSize) {
val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
val clippedRatio = min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = Bitmap.createScaledBitmap(img, width, height, true)
str = compressImage(img)
str = compressImageStr(img)
}
return str
}
private fun compressImage(bitmap: Bitmap): String {
private fun compressImageStr(bitmap: Bitmap): String {
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
}
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): ByteArrayOutputStream {
var img = image
var stream = compressImageData(img)
while (stream.size() > maxDataSize) {
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
val clippedRatio = min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = Bitmap.createScaledBitmap(img, width, height, true)
stream = compressImageData(img)
}
return stream
}
private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
return stream
}
fun base64ToBitmap(base64ImageString: String) : Bitmap {

View File

@@ -42,7 +42,7 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
if (imageUri != null) {
try {
val stream = java.net.URL(imageUri).openStream()
val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
// TODO add once supported in iOS
// val description = ogTags.firstOrNull {
// it.attr("property") == "og:description"

View File

@@ -199,6 +199,9 @@ private fun spannableStringToAnnotatedString(
}
}
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE = 236700
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
}

View File

@@ -79,7 +79,7 @@ fun UserProfileLayout(
sheetContent = {
GetImageBottomSheet(
chosenImage,
onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) },
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})

View File

@@ -78,6 +78,7 @@
<!-- Images -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_cancel_image_preview">удалить превью изображения</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Удалить контакт?</string>
@@ -167,7 +168,7 @@
<string name="your_chat_profile_will_be_sent_to_your_contact">Ваш профиль будет отправлен\nвашему контакту</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видеозвонка</b>, или ваш контакт может отправить вам ссылку.</string>
<string name="share_invitation_link">Поделиться ссылкой</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта..</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
<!-- settings - SettingsView.kt -->

View File

@@ -78,6 +78,7 @@
<!-- Images -->
<string name="image_descr">Image</string>
<string name="icon_descr_cancel_image_preview">cancel image preview</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Delete contact?</string>

View File

@@ -9,6 +9,9 @@
import Foundation
import SwiftUI
// maximum image file size to be auto-accepted
let maxImageSize = 236700
func getDocumentsDirectory() -> URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}

View File

@@ -743,8 +743,7 @@ func processReceivedMsg(_ res: ChatResponse) {
let cItem = aChatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
if let file = cItem.file,
file.fileSize <= 236700 {
// file.fileSize <= 394500 {
file.fileSize <= maxImageSize {
Task {
do {
try await receiveFile(fileId: file.fileId)

View File

@@ -14,19 +14,12 @@ struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
@State var message: String = ""
@State var quotedItem: ChatItem? = nil
@State var editingItem: ChatItem? = nil
@State var linkPreview: LinkPreview? = nil
@State var composeState = ComposeState()
@State var deletingItem: ChatItem? = nil
@State private var inProgress: Bool = false
@FocusState private var keyboardVisible: Bool
@State private var showChatInfo = false
@State private var showDeleteMessage = false
@State private var chosenImage: UIImage? = nil
@State private var imagePreview: String? = nil
var body: some View {
let cInfo = chat.chatInfo
@@ -86,16 +79,9 @@ struct ChatView: View {
Spacer(minLength: 0)
ComposeView(
message: $message,
quotedItem: $quotedItem,
editingItem: $editingItem,
linkPreview: $linkPreview,
sendMessage: sendMessage,
resetMessage: { message = "" },
inProgress: inProgress,
keyboardVisible: $keyboardVisible,
chosenImage: $chosenImage,
imagePreview: $imagePreview
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
}
.navigationTitle(cInfo.chatViewName)
@@ -130,8 +116,7 @@ struct ChatView: View {
if ci.isMsgContent() {
Button {
withAnimation {
editingItem = nil
quotedItem = ci
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
}
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {
@@ -142,7 +127,9 @@ struct ChatView: View {
showShareSheet(items: shareItems)
} label: { Label("Share", systemImage: "square.and.arrow.up") }
Button {
if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) {
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getStoredImage(ci.file) {
UIPasteboard.general.image = image
} else {
UIPasteboard.general.string = ci.content.text
@@ -151,9 +138,7 @@ struct ChatView: View {
if ci.meta.editable {
Button {
withAnimation {
quotedItem = nil
editingItem = ci
message = ci.content.text
composeState = ComposeState(editingItem: ci)
}
} label: { Label("Edit", systemImage: "square.and.pencil") }
}
@@ -214,75 +199,6 @@ struct ChatView: View {
}
}
}
func sendMessage(_ text: String) {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
do {
if let ei = editingItem {
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
msg: .text(text)
)
DispatchQueue.main.async {
editingItem = nil
linkPreview = nil
let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem)
}
} else {
let mc: MsgContent
var file: String? = nil
if let preview = imagePreview,
let uiImage = chosenImage,
let savedFile = saveImage(uiImage) {
mc = .image(text: text, image: preview)
file = savedFile
} else if let preview = linkPreview {
mc = .link(text: text, preview: preview)
} else {
mc = .text(text)
}
let chatItem = try await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quotedItem?.meta.itemId,
msg: mc
)
DispatchQueue.main.async {
quotedItem = nil
linkPreview = nil
chosenImage = nil
imagePreview = nil
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
} catch {
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
}
}
}
func saveImage(_ uiImage: UIImage) -> String? {
if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000),
let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)),
let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) {
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
let fileToSave = "image_\(millisecondsSince1970).jpg"
let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave)
do {
try jpegData.write(to: filePath)
return fileToSave
} catch {
logger.error("ChatView.saveImage error: \(error.localizedDescription)")
return nil
}
}
return nil
}
func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")

View File

@@ -8,43 +8,106 @@
import SwiftUI
// TODO
//enum ComposeState {
// case plain
// case quoted(quotedItem: ChatItem)
// case editing(editingItem: ChatItem)
//}
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview)
case imagePreview(imagePreview: String)
}
//enum ReferencedItem {
// case none
// case quoted(quotedItem: ChatItem)
// case editing(editingItem: ChatItem)
//}
//
//enum Preview {
// case none
// case link(linkPreview: LinkPreview)
// case image(image: UIImage)
//}
//
//struct ComposeState {
// var quotedItem: ChatItem? = nil
// var editingItem: ChatItem? = nil
// var linkPreview: LinkPreview? = nil
//}
enum ComposeContextItem {
case noContextItem
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
}
struct ComposeState {
var message: String
var preview: ComposePreview
var contextItem: ComposeContextItem
var inProgress: Bool = false
init(
message: String = "",
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem
) {
self.message = message
self.preview = preview
self.contextItem = contextItem
}
init(editingItem: ChatItem) {
self.message = editingItem.content.text
self.preview = chatItemPreview(chatItem: editingItem)
self.contextItem = .editingItem(chatItem: editingItem)
}
func copy(
message: String? = nil,
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem
)
}
func editing() -> Bool {
switch contextItem {
case .editingItem: return true
default: return false
}
}
func sendEnabled() -> Bool {
switch preview {
case .imagePreview:
return true
default:
return !message.isEmpty
}
}
func linkPreviewAllowed() -> Bool {
switch preview {
case .imagePreview:
return false
default:
return true
}
}
func linkPreview() -> LinkPreview? {
switch preview {
case let .linkPreview(linkPreview):
return linkPreview
default:
return nil
}
}
}
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
let chatItemPreview: ComposePreview
switch chatItem.content.msgContent {
case let .link(_, preview: preview):
chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image: image):
chatItemPreview = .imagePreview(imagePreview: image)
default:
chatItemPreview = .noPreview
}
return chatItemPreview
}
struct ComposeView: View {
@Binding var message: String
@Binding var quotedItem: ChatItem?
@Binding var editingItem: ChatItem?
@Binding var linkPreview: LinkPreview?
var sendMessage: (String) -> Void
var resetMessage: () -> Void
var inProgress: Bool = false
@EnvironmentObject var chatModel: ChatModel
let chat: Chat
@Binding var composeState: ComposeState
@FocusState.Binding var keyboardVisible: Bool
@State var editing: Bool = false
@State var sendEnabled: Bool = false
@State var linkUrl: URL? = nil
@State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil
@@ -53,59 +116,43 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@Binding var chosenImage: UIImage?
@Binding var imagePreview: String?
@State var chosenImage: UIImage?
var body: some View {
VStack(spacing: 0) {
if let metadata = imagePreview {
ComposeImageView(image: metadata, cancelImage: nil)
} else if let metadata = linkPreview {
ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview)
}
if (quotedItem != nil) {
ContextItemView(contextItem: $quotedItem, editing: $editing)
} else if (editingItem != nil) {
ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage)
}
HStack{
// Button {
// showChooseSource = true
// } label: {
// Image(systemName: "paperclip")
// .resizable()
// }
// .disabled(editingItem != nil)
// .frame(width: 25, height: 25)
// .padding(.vertical, 4)
// .padding(.leading, 12)
contextItemView()
previewView()
HStack (alignment: .bottom) {
Button {
showChooseSource = true
} label: {
Image(systemName: "paperclip")
.resizable()
}
.disabled(composeState.editing())
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.leading, 12)
SendMessageView(
composeState: $composeState,
sendMessage: { text in
sendMessage(text)
resetLinkPreview()
},
inProgress: inProgress,
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editing,
sendEnabled: $sendEnabled
keyboardVisible: $keyboardVisible
)
.padding(.horizontal, 12)
// // use this padding when attach button is uncommented
// .padding(.trailing, 12)
.padding(.trailing, 12)
.background(.background)
}
}
.onChange(of: message) { _ in
if message.count > 0 {
showLinkPreview(message)
} else {
resetLinkPreview()
.onChange(of: composeState.message) { _ in
if composeState.linkPreviewAllowed() {
if composeState.message.count > 0 {
showLinkPreview(composeState.message)
} else {
resetLinkPreview()
}
}
sendEnabled = (imagePreview != nil || !message.isEmpty)
}
.onChange(of: editingItem == nil) { _ in
editing = (editingItem != nil)
}
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
@@ -128,22 +175,133 @@ struct ComposeView: View {
}
}
.onChange(of: chosenImage) { image in
if let image = image {
imagePreview = resizeImageToDataSize(image, maxDataSize: 12500)
if let image = image,
let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) {
composeState = composeState.copy(preview: .imagePreview(imagePreview: imagePreview))
} else {
imagePreview = nil
composeState = composeState.copy(preview: .noPreview)
}
}
.onChange(of: imagePreview) { _ in
sendEnabled = (imagePreview != nil || !message.isEmpty)
}
@ViewBuilder func previewView() -> some View {
switch composeState.preview {
case .noPreview:
EmptyView()
case let .linkPreview(linkPreview: preview):
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
case let .imagePreview(imagePreview: img):
ComposeImageView(
image: img,
cancelImage: { composeState = composeState.copy(preview: .noPreview) },
cancelEnabled: !composeState.editing())
}
}
@ViewBuilder private func contextItemView() -> some View {
switch composeState.contextItem {
case .noContextItem:
EmptyView()
case let .quotedItem(chatItem: quotedItem):
ContextItemView(contextItem: quotedItem, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) })
case let .editingItem(chatItem: editingItem):
ContextItemView(contextItem: editingItem, cancelContextItem: { composeState = ComposeState()})
}
}
private func sendMessage(_ text: String) {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
do {
switch composeState.contextItem {
case let .editingItem(chatItem: ei):
if let oldMsgContent = ei.content.msgContent {
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
msg: updateMsgContent(oldMsgContent)
)
DispatchQueue.main.async {
let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
}
}
default:
var mc: MsgContent? = nil
var file: String? = nil
switch (composeState.preview) {
case .noPreview:
mc = .text(composeState.message)
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
}
}
var quotedItemId: Int64? = nil
switch (composeState.contextItem) {
case let .quotedItem(chatItem: quotedItem):
quotedItemId = quotedItem.id
default:
quotedItemId = nil
}
if let mc = mc {
let chatItem = try await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quotedItemId,
msg: mc
)
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
composeState = ComposeState()
} catch {
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
}
}
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
switch msgContent {
case .text:
return checkLinkPreview()
case .link:
return checkLinkPreview()
case .image(_, let image):
return .image(text: composeState.message, image: image)
case .unknown(let type, _):
return .unknown(type: type, text: composeState.message)
}
}
private func saveImage(_ uiImage: UIImage) -> String? {
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) {
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
let fileToSave = "image_\(millisecondsSince1970).jpg"
let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave)
do {
try imageDataResized.write(to: filePath)
return fileToSave
} catch {
logger.error("ChatView.saveImage error: \(error.localizedDescription)")
return nil
}
}
return nil
}
private func showLinkPreview(_ s: String) {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)
if let url = linkUrl {
if url != linkPreview?.uri && url != pendingLinkUrl {
if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
if prevLinkUrl == url {
loadLinkPreview(url)
@@ -154,7 +312,7 @@ struct ComposeView: View {
}
}
} else {
linkPreview = nil
composeState = composeState.copy(preview: .noPreview)
}
}
@@ -162,7 +320,7 @@ struct ComposeView: View {
do {
let parsedMsg = try apiParseMarkdown(text: msg)
let uri = parsedMsg?.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
})
if let uri = uri { return URL(string: uri.text) }
else { return nil }
@@ -176,18 +334,19 @@ struct ComposeView: View {
link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")
}
private func cancelPreview() {
if let uri = linkPreview?.uri.absoluteString {
private func cancelLinkPreview() {
if let uri = composeState.linkPreview()?.uri.absoluteString {
cancelledLinks.insert(uri)
}
linkPreview = nil
composeState = composeState.copy(preview: .noPreview)
}
private func loadLinkPreview(_ url: URL) {
if pendingLinkUrl == url {
getLinkPreview(url: url) { lp in
if pendingLinkUrl == url {
linkPreview = lp
getLinkPreview(url: url) { linkPreview in
if let linkPreview = linkPreview,
pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
pendingLinkUrl = nil
}
}
@@ -200,40 +359,38 @@ struct ComposeView: View {
pendingLinkUrl = nil
cancelledLinks = []
}
private func checkLinkPreview() -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(composeState.message),
url == linkPreview.uri {
return .link(text: composeState.message, preview: linkPreview)
} else {
return .text(composeState.message)
}
default:
return .text(composeState.message)
}
}
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
@State var message: String = ""
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@State var composeState = ComposeState(message: "hello")
@FocusState var keyboardVisible: Bool
@State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var nilItem: ChatItem? = nil
@State var linkPreview: LinkPreview? = nil
@State var chosenImage: UIImage? = nil
@State var imagePreview: String? = nil
return Group {
ComposeView(
message: $message,
quotedItem: $item,
editingItem: $nilItem,
linkPreview: $linkPreview,
sendMessage: { print ($0) },
resetMessage: {},
keyboardVisible: $keyboardVisible,
chosenImage: $chosenImage,
imagePreview: $imagePreview
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
ComposeView(
message: $message,
quotedItem: $nilItem,
editingItem: $item,
linkPreview: $linkPreview,
sendMessage: { print ($0) },
resetMessage: {},
keyboardVisible: $keyboardVisible,
chosenImage: $chosenImage,
imagePreview: $imagePreview
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
}
}

View File

@@ -10,31 +10,25 @@ import SwiftUI
struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme
@Binding var contextItem: ChatItem?
@Binding var editing: Bool
var resetMessage: () -> Void = {}
let contextItem: ChatItem
let cancelContextItem: () -> Void
var body: some View {
if let cxtItem = contextItem {
HStack {
contextText(cxtItem).lineLimit(3)
Spacer()
Button {
withAnimation {
contextItem = nil
if editing { resetMessage() }
}
} label: {
Image(systemName: "multiply")
HStack {
contextText(contextItem).lineLimit(3)
Spacer()
Button {
withAnimation {
cancelContextItem()
}
} label: {
Image(systemName: "multiply")
}
.padding(12)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(cxtItem, colorScheme))
.padding(.top, 8)
} else {
EmptyView()
}
.padding(12)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
}
func contextText(_ cxtItem: ChatItem) -> some View {
@@ -48,8 +42,7 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
@State var contextItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var editing: Bool = false
return ContextItemView(contextItem: $contextItem, editing: $editing)
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(contextItem: contextItem, cancelContextItem: {})
}
}

View File

@@ -9,13 +9,10 @@
import SwiftUI
struct SendMessageView: View {
@Binding var composeState: ComposeState
var sendMessage: (String) -> Void
var inProgress: Bool = false
@Binding var message: String
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
@Binding var editing: Bool
@Binding var sendEnabled: Bool
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
var maxHeight: CGFloat = 360
@@ -25,15 +22,15 @@ struct SendMessageView: View {
ZStack {
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
Text(message)
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
TextEditor(text: $message)
.onSubmit(submit)
TextEditor(text: $composeState.message)
.focused($keyboardVisible)
.font(teFont)
.textInputAutocapitalization(.sentences)
@@ -42,18 +39,18 @@ struct SendMessageView: View {
.frame(height: teHeight)
}
if (inProgress) {
if (composeState.inProgress) {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 3)
} else {
Button(action: submit) {
Image(systemName: editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
Button(action: { sendMessage(composeState.message) }) {
Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
}
.disabled(!sendEnabled)
.disabled(!composeState.sendEnabled())
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
@@ -66,16 +63,11 @@ struct SendMessageView: View {
.padding(.vertical, 8)
}
func submit() {
sendMessage(message)
message = ""
}
func updateHeight(_ g: GeometryProxy) -> Color {
DispatchQueue.main.async {
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
teFont = isShortEmoji(message)
? message.count < 4
teFont = isShortEmoji(composeState.message)
? composeState.message.count < 4
? largeEmojiFont
: mediumEmojiFont
: .body
@@ -86,34 +78,29 @@ struct SendMessageView: View {
struct SendMessageView_Previews: PreviewProvider {
static var previews: some View {
@State var message: String = ""
@State var composeStateNew = ComposeState()
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var composeStateEditing = ComposeState(editingItem: ci)
@FocusState var keyboardVisible: Bool
@State var editingOff: Bool = false
@State var editingOn: Bool = true
@State var sendEnabled: Bool = true
@State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
return Group {
VStack {
Text("")
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateNew,
sendMessage: { print ($0) },
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editingOff,
sendEnabled: $sendEnabled
keyboardVisible: $keyboardVisible
)
}
VStack {
Text("")
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateEditing,
sendMessage: { print ($0) },
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editingOn,
sendEnabled: $sendEnabled
keyboardVisible: $keyboardVisible
)
}
}

View File

@@ -11,7 +11,8 @@ import SwiftUI
struct ComposeImageView: View {
@Environment(\.colorScheme) var colorScheme
let image: String
var cancelImage: (() -> Void)? = nil
let cancelImage: (() -> Void)
let cancelEnabled: Bool
var body: some View {
HStack(alignment: .center, spacing: 8) {
@@ -20,9 +21,10 @@ struct ComposeImageView: View {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 80, maxHeight: 60)
.frame(maxWidth: 80, minHeight: 40, maxHeight: 60)
}
if let cancelImage = cancelImage {
Spacer()
if cancelEnabled {
Button { cancelImage() } label: {
Image(systemName: "multiply")
}
@@ -35,3 +37,30 @@ struct ComposeImageView: View {
.padding(.top, 8)
}
}
//struct ComposeImageView: View {
// @Environment(\.colorScheme) var colorScheme
// let image: String
// let cancelImage: (() -> Void)
//
// var body: some View {
// if let data = Data(base64Encoded: dropImagePrefix(image)),
// let uiImage = UIImage(data: data) {
// HStack(alignment: .center) {
// ZStack(alignment: .topTrailing) {
// Image(uiImage: uiImage)
// .resizable()
// .scaledToFit()
// .cornerRadius(20)
// .frame(maxHeight: 150)
// Button { cancelImage() } label: {
// Image(systemName: "multiply")
// .foregroundColor(.white)
// }
// .padding(8)
// }
// }
// .padding(.top, 8)
// }
// }
//}

View File

@@ -25,7 +25,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToDataSize(image, maxDataSize: 14000),
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)

View File

@@ -46,28 +46,43 @@ func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
return resizeImage(image, newBounds: bounds, drawIn: bounds)
}
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? {
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
var img = image
var str = compressImage(img)
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 = compressImage(img)
str = compressImageStr(img)
dataSize = str?.count ?? 0
}
logger.debug("resizeImageToDataSize final \(dataSize)")
logger.debug("resizeImageToStrSize final \(dataSize)")
return str
}
func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
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

View File

@@ -14,11 +14,8 @@ private let maxItemSize: Int = 50000
struct TerminalView: View {
@EnvironmentObject var chatModel: ChatModel
@State var inProgress: Bool = false
@State var message: String = ""
@State var composeState: ComposeState = ComposeState()
@FocusState private var keyboardVisible: Bool
@State var editing: Bool = false
@State var sendEnabled: Bool = false
var body: some View {
VStack {
@@ -64,21 +61,15 @@ struct TerminalView: View {
Spacer()
SendMessageView(
composeState: $composeState,
sendMessage: sendMessage,
inProgress: inProgress,
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editing,
sendEnabled: $sendEnabled
keyboardVisible: $keyboardVisible
)
.padding(.horizontal, 12)
}
}
.navigationViewStyle(.stack)
.navigationTitle("Chat console")
.onChange(of: message) { _ in
sendEnabled = !message.isEmpty
}
}
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
@@ -93,11 +84,12 @@ struct TerminalView: View {
let cmd = ChatCommand.string(cmdStr)
DispatchQueue.global().async {
Task {
inProgress = true
composeState.inProgress = true
_ = await chatSendCmd(cmd)
inProgress = false
composeState.inProgress = false
}
}
composeState = ComposeState()
}
}

View File

@@ -99,7 +99,7 @@ struct UserProfile: View {
}
.onChange(of: chosenImage) { image in
if let image = image {
profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500)
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
} else {
profile.image = nil
}