mobile: correctly resize images, refine image sending UI (#546)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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() } }
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user