From 1b930e717a96df47c71047dd4dceeeccec753b44 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Mon, 11 Apr 2022 09:39:04 +0100 Subject: [PATCH] android: link previews (#510) * wire up api for link metadata parsing * add getLinkPreview (synchonous for now) * api wiring fix * get network requests off main thread * copy over state machine logic from iOS * filter api parsing calls from logs * refactor of image processing * remove image deepcopy * minor change to log filtering * mobile: link previews Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/build.gradle | 3 + .../java/chat/simplex/app/model/ChatModel.kt | 48 +++++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 26 +++- .../chat/simplex/app/views/TerminalView.kt | 10 +- .../chat/simplex/app/views/chat/ChatView.kt | 28 ++-- .../simplex/app/views/chat/ComposeView.kt | 26 +++- .../simplex/app/views/chat/SendMsgView.kt | 80 +++++++++- .../app/views/chat/item/FramedItemView.kt | 23 ++- .../simplex/app/views/helpers/GetImageView.kt | 46 +++--- .../simplex/app/views/helpers/LinkPreviews.kt | 140 ++++++++++++++++++ apps/android/build.gradle | 6 +- .../Chat/ComposeMessage/ComposeView.swift | 93 ++++++------ .../Views/Helpers/ChatItemLinkView.swift | 10 +- .../Views/Helpers/ComposeLinkView.swift | 2 +- .../Shared/Views/Helpers/ImagePicker.swift | 63 ++++---- .../Views/UserSettings/UserProfile.swift | 2 +- 16 files changed, 467 insertions(+), 139 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 5932b59f80..1aa20134be 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -96,6 +96,9 @@ dependencies { //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" + // Link Previews + implementation 'org.jsoup:jsoup:1.13.1' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 3eb4ddedfa..71185d1feb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -457,6 +457,23 @@ class GroupMember ( } } +@Serializable +class LinkPreview ( + val uri: String, + val title: String, + val description: String, + val image: String +) { + companion object { + val sampleData = LinkPreview( + uri = "https://www.duckduckgo.com", + title = "Privacy, simplified.", + description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.", + image = "data:image/jpg;base64,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" + ) + } +} + @Serializable class MemberSubError ( val member: GroupMember, @@ -659,35 +676,40 @@ interface ItemContent { @Serializable sealed class CIContent: ItemContent { abstract override val text: String + abstract val msgContent: MsgContent? @Serializable @SerialName("sndMsgContent") - class SndMsgContent(val msgContent: MsgContent): CIContent() { + class SndMsgContent(override val msgContent: MsgContent): CIContent() { override val text get() = msgContent.text } @Serializable @SerialName("rcvMsgContent") - class RcvMsgContent(val msgContent: MsgContent): CIContent() { + class RcvMsgContent(override val msgContent: MsgContent): CIContent() { override val text get() = msgContent.text } @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val text get() = "deleted" + override val msgContent get() = null } @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val text get() = "deleted" + override val msgContent get() = null } @Serializable @SerialName("sndFileInvitation") class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { override val text get() = "sending files is not supported yet" + override val msgContent get() = null } @Serializable @SerialName("rcvFileInvitation") class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() { override val text get() = "receiving files is not supported yet" + override val msgContent get() = null } } @@ -716,15 +738,23 @@ class CIQuote ( } } +@Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = MsgContentSerializer::class) sealed class MsgContent { abstract val text: String + @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() + + @Serializable(with = MsgContentSerializer::class) + class MCLink(override val text: String, val preview: LinkPreview): MsgContent() + + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val cmdString: String get() = when (this) { is MCText -> "text $text" + is MCLink -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } @@ -735,6 +765,10 @@ object MsgContentSerializer : KSerializer { element("MCText", buildClassSerialDescriptor("MCText") { element("text") }) + element("MCLink", buildClassSerialDescriptor("MCLink") { + element("text") + element("preview") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -747,6 +781,10 @@ object MsgContentSerializer : KSerializer { val text = json["text"]?.jsonPrimitive?.content ?: "unknown message format" when (t) { "text" -> MsgContent.MCText(text) + "link" -> { + val preview = Json.decodeFromString(json["preview"].toString()) + MsgContent.MCLink(text, preview) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -765,6 +803,12 @@ object MsgContentSerializer : KSerializer { put("type", "text") put("text", value.text) } + is MsgContent.MCLink -> + buildJsonObject { + put("type", "link") + put("text", value.text) + put("preview", json.encodeToJsonElement(value.preview)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 53968f7719..eeb7b5d046 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -15,8 +15,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -76,15 +75,19 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt suspend fun sendCmd(cmd: CC): CR { return withContext(Dispatchers.IO) { val c = cmd.cmdString - chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + if (cmd !is CC.ApiParseMarkdown) { + chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + Log.d(TAG, "sendCmd: ${cmd.cmdType}") + } val json = chatSendCmd(ctrl, c) - Log.d(TAG, "sendCmd: ${cmd.cmdType}") val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { Log.d(TAG, "sendCmd response json $json") } - chatModel.terminalItems.add(TerminalItem.resp(r.resp)) + if (r.resp !is CR.ParsedMarkdown) { + chatModel.terminalItems.add(TerminalItem.resp(r.resp)) + } r.resp } } @@ -240,6 +243,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return null } + suspend fun apiParseMarkdown(text: String): List? { + val r = sendCmd(CC.ApiParseMarkdown(text)) + if (r is CR.ParsedMarkdown) return r.formattedText + Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiCreateUserAddress(): String? { val r = sendCmd(CC.CreateMyAddress()) if (r is CR.UserContactLinkCreated) return r.connReqContact @@ -479,6 +489,7 @@ sealed class CC { class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiUpdateProfile(val profile: Profile): CC() + class ApiParseMarkdown(val text: String): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -503,6 +514,7 @@ sealed class CC { is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" + is ApiParseMarkdown -> "/_parse $text" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -528,6 +540,7 @@ sealed class CC { is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" is ApiUpdateProfile -> "updateProfile" + is ApiParseMarkdown -> "apiParseMarkdown" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" @@ -588,6 +601,7 @@ sealed class CR { @Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() @@ -628,6 +642,7 @@ sealed class CR { is ContactDeleted -> "contactDeleted" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" + is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkCreated -> "userContactLinkCreated" is UserContactLinkDeleted -> "userContactLinkDeleted" @@ -669,6 +684,7 @@ sealed class CR { is ContactDeleted -> json.encodeToString(contact) is UserProfileNoChange -> noDetails() is UserProfileUpdated -> json.encodeToString(toProfile) + is ParsedMarkdown -> json.encodeToString(formattedText) is UserContactLink -> connReqContact is UserContactLinkCreated -> connReqContact is UserContactLinkDeleted -> noDetails() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 98b828c42e..8f828f4e19 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -43,7 +43,15 @@ fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCom ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, - bottomBar = { SendMsgView(msg = remember { mutableStateOf("") }, sendCommand) }, + bottomBar = { + SendMsgView( + msg = remember { mutableStateOf("") }, + linkPreview = remember { mutableStateOf(null) }, + cancelledLinks = remember { mutableSetOf() }, + parseMarkdown = { null }, + sendMessage = sendCommand + ) + }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 02b8ab1da6..d8c5bd016e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -31,8 +31,7 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.ModalManager import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.datetime.Clock @Composable @@ -44,7 +43,9 @@ fun ChatView(chatModel: ChatModel) { } else { val quotedItem = remember { mutableStateOf(null) } val editingItem = remember { mutableStateOf(null) } + val linkPreview = remember { mutableStateOf(null) } var msg = remember { mutableStateOf("") } + BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -61,7 +62,7 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, + ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -84,17 +85,19 @@ fun ChatView(chatModel: ChatModel) { ) if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) } else { + val linkPreviewData = linkPreview.value val newItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, quotedItemId = quotedItem.value?.meta?.itemId, - mc = MsgContent.MCText(msg) + mc = if (linkPreviewData != null) MsgContent.MCLink(msg, linkPreviewData) else MsgContent.MCText(msg) ) if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) } // hide "in progress" editingItem.value = null quotedItem.value = null + linkPreview.value = null } }, resetMessage = { msg.value = "" }, @@ -109,7 +112,8 @@ fun ChatView(chatModel: ChatModel) { ) if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } - } + }, + parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } } ) } } @@ -122,12 +126,14 @@ fun ChatLayout( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, + linkPreview: MutableState, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, resetMessage: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + parseMarkdown: (String) -> List? ) { Surface( Modifier @@ -137,7 +143,7 @@ fun ChatLayout( ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) }, + bottomBar = { ComposeView(msg, quotedItem, editingItem, linkPreview, sendMessage, resetMessage, parseMarkdown) }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { @@ -334,12 +340,14 @@ fun PreviewChatLayout() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, + linkPreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + parseMarkdown = { null } ) } } @@ -377,12 +385,14 @@ fun PreviewGroupChatLayout() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, + linkPreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + parseMarkdown = { null } ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 3cea4b1f52..2c1973b054 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,9 +1,9 @@ package chat.simplex.app.views.chat -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import chat.simplex.app.model.ChatItem +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.ComposeLinkView // TODO ComposeState @@ -12,10 +12,24 @@ fun ComposeView( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, + linkPreview: MutableState, sendMessage: (String) -> Unit, - resetMessage: () -> Unit + resetMessage: () -> Unit, + parseMarkdown: (String) -> List? ) { + val cancelledLinks = remember { mutableSetOf() } + + fun cancelPreview() { + val uri = linkPreview.value?.uri + if (uri != null) { + cancelledLinks.add(uri) + } + linkPreview.value = null + } + Column { + val lp = linkPreview.value + if (lp != null) ComposeLinkView(lp, ::cancelPreview) when { quotedItem.value != null -> { ContextItemView(quotedItem) @@ -25,6 +39,6 @@ fun ComposeView( } else -> {} } - SendMsgView(msg, sendMessage, editing = editingItem.value != null) + SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 89b5851f89..7d1cb8d05b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -1,6 +1,7 @@ package chat.simplex.app.views.chat import android.content.res.Configuration +import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -20,22 +21,83 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.TAG +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.getLinkPreview +import chat.simplex.app.views.helpers.withApi +import kotlinx.coroutines.delay @Composable -fun SendMsgView(msg: MutableState, sendMessage: (String) -> Unit, editing: Boolean = false) { +fun SendMsgView( + msg: MutableState, + linkPreview: MutableState, + cancelledLinks: MutableSet, + parseMarkdown: (String) -> List?, + sendMessage: (String) -> Unit, + editing: Boolean = false +) { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) var textStyle by remember { mutableStateOf(smallFont) } + val linkUrl = remember { mutableStateOf(null) } + val prevLinkUrl = remember { mutableStateOf(null) } + val pendingLinkUrl = remember { mutableStateOf(null) } + + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = parseMarkdown(msg) + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (pendingLinkUrl.value == url) { + linkPreview.value = lp + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + linkPreview.value = null + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + BasicTextField( value = msg.value, - onValueChange = { - msg.value = it - textStyle = if (isShortEmoji(it)) { - if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + onValueChange = { s -> + msg.value = s + if (isShortEmoji(s)) { + textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { - smallFont + textStyle = smallFont + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() } }, textStyle = textStyle, @@ -99,6 +161,9 @@ fun PreviewSendMsgView() { SimpleXTheme { SendMsgView( msg = remember { mutableStateOf("") }, + linkPreview = remember {mutableStateOf(null) }, + cancelledLinks = mutableSetOf(), + parseMarkdown = { null }, sendMessage = { msg -> println(msg) } ) } @@ -115,7 +180,10 @@ fun PreviewSendMsgViewEditing() { SimpleXTheme { SendMsgView( msg = remember { mutableStateOf("") }, + linkPreview = remember {mutableStateOf(null) }, + cancelledLinks = mutableSetOf(), sendMessage = { msg -> println(msg) }, + parseMarkdown = { null }, editing = true ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 302f3ee1dc..54860a84f8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.ChatItemLinkView import kotlinx.datetime.Clock val SentColorLight = Color(0x1E45B8FF) @@ -45,8 +46,8 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho ) } } - Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { Column( Modifier .padding(bottom = 2.dp) @@ -56,11 +57,19 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho EmojiText(ci.content.text) Text("") } - } else { - MarkdownText( - ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, - metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true - ) + } + } else { + Column(Modifier.fillMaxWidth()) { + val mc = ci.content.msgContent + if (mc is MsgContent.MCLink) { + ChatItemLinkView(mc.preview) + } + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + MarkdownText( + ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, + metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true + ) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 6ae0c384a6..8c196e6ada 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -32,32 +32,40 @@ import chat.simplex.app.TAG import chat.simplex.app.views.newchat.ActionButton import java.io.ByteArrayOutputStream import java.io.File +import kotlin.math.min +import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery -fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String { - val size = 104 - var height = size - var width = size +private fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 - if (bitmap.height < bitmap.width) { - width = height * bitmap.width / bitmap.height - xOffset = (width - height) / 2 + val side = min(image.height, image.width) + if (image.height < image.width) { + xOffset = (image.width - side) / 2 } else { - height = width * bitmap.height / bitmap.width - yOffset = (height - width) / 2 + yOffset = (image.height - side) / 2 } - var image = bitmap - while (image.width / 2 > width) { - image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true) - } - image = Bitmap.createScaledBitmap(image, width, height, true) - if (squareCrop) { - image = Bitmap.createBitmap(image, xOffset, yOffset, size, size) + return Bitmap.createBitmap(image, xOffset, yOffset, side, side) +} + +fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String { + var img = image + var str = compressImage(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) } + return str +} + +private fun compressImage(bitmap: Bitmap): String { val stream = ByteArrayOutputStream() - image.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) } @@ -126,12 +134,12 @@ fun GetImageBottomSheet( if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) - profileImageStr.value = bitmapToBase64(bitmap) + profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) } } val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> - if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap) + if (bitmap != null) profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) } val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt new file mode 100644 index 0000000000..102097457f --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -0,0 +1,140 @@ +package chat.simplex.app.views.helpers + +import android.content.res.Configuration +import android.graphics.BitmapFactory +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.LinkPreview +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.SentColorLight +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup + +private const val OG_SELECT_QUERY = "meta[property^=og:]" + +suspend fun getLinkPreview(url: String): LinkPreview? { + return withContext(Dispatchers.IO) { + try { + val response = Jsoup.connect(url) + .ignoreContentType(true) + .timeout(10000) + .followRedirects(true) + .execute() + val doc = response.parse() + val ogTags = doc.select(OG_SELECT_QUERY) + val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content") + if (imageUri != null) { + try { + val stream = java.net.URL(imageUri).openStream() + val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) +// TODO add once supported in iOS +// val description = ogTags.firstOrNull { +// it.attr("property") == "og:description" +// }?.attr("content") ?: "" + val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") + if (title != null) { + return@withContext LinkPreview(url, title, description = "", image) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return@withContext null + } +} + + + +@Composable +fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() + Image( + imageBitmap, + "preview image", + modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) + ) + Column(Modifier.fillMaxWidth().weight(1F)) { + Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Cancel Preview", + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } +} + +@Composable +fun ChatItemLinkView(linkPreview: LinkPreview) { + Column { + Image( + base64ToBitmap(linkPreview.image).asImageBitmap(), + "link image", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { + Text(linkPreview.title, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, modifier = Modifier.padding(bottom = 4.dp)) + if (linkPreview.description != "") { + Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp) + } + Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight) + } + } +} + + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "ChatItemLinkView (Dark Mode)" +) +@Composable +fun PreviewChatItemLinkView() { + SimpleXTheme { + ChatItemLinkView(LinkPreview.sampleData) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "ComposeLinkView (Dark Mode)" +) +@Composable +fun PreviewComposeLinkView() { + SimpleXTheme { + ComposeLinkView(LinkPreview.sampleData) { -> } + } +} \ No newline at end of file diff --git a/apps/android/build.gradle b/apps/android/build.gradle index ad0b119f62..231378f393 100644 --- a/apps/android/build.gradle +++ b/apps/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + classpath 'com.android.tools.build:gradle:7.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2" @@ -16,8 +16,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.1.2' apply false - id 'com.android.library' version '7.1.2' apply false + id 'com.android.application' version '7.1.3' apply false + id 'com.android.library' version '7.1.3' apply false id 'org.jetbrains.kotlin.android' version '1.6.10' apply false id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index f71c810434..72c5e66449 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -32,34 +32,6 @@ struct ComposeView: View { @State var cancelledLinks: Set = [] - private func isValidLink(link: String) -> Bool { - return !(link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")) - } - - func cancelPreview() { - if let uri = linkPreview?.uri.absoluteString { - cancelledLinks.insert(uri) - } - linkPreview = nil - } - - func parseMessage(_ msg: String) -> URL? { - do { - if let parsedMsg = try apiParseMarkdown(text: msg), - let link = parsedMsg.first(where: { - $0.format == .uri && !cancelledLinks.contains($0.text) - }), - isValidLink(link: link.text) { - return URL(string: link.text) - } else { - return nil - } - } catch { - logger.error("apiParseMarkdown error: \(error.localizedDescription)") - return nil - } - } - var body: some View { VStack(spacing: 0) { if let metadata = linkPreview { @@ -84,19 +56,7 @@ struct ComposeView: View { } .onChange(of: message) { _ in if message.count > 0 { - prevLinkUrl = linkUrl - linkUrl = parseMessage(message) - if let url = linkUrl { - if prevLinkUrl == linkUrl { - loadLinkPreview(url) - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - loadLinkPreview(url) - } - } - } else { - linkPreview = nil - } + showLinkPreview(message) } else { resetLinkPreview() } @@ -106,9 +66,52 @@ struct ComposeView: View { } } - func loadLinkPreview(_ url: URL) { - if url != linkPreview?.uri && url != pendingLinkUrl { - pendingLinkUrl = url + private func showLinkPreview(_ s: String) { + prevLinkUrl = linkUrl + linkUrl = parseMessage(s) + if let url = linkUrl { + if url != linkPreview?.uri && url != pendingLinkUrl { + pendingLinkUrl = url + if prevLinkUrl == url { + loadLinkPreview(url) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + loadLinkPreview(url) + } + } + } + } else { + linkPreview = nil + } + } + + private func parseMessage(_ msg: String) -> URL? { + do { + let parsedMsg = try apiParseMarkdown(text: msg) + let uri = parsedMsg?.first(where: { ft in + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + }) + if let uri = uri { return URL(string: uri.text) } + else { return nil } + } catch { + logger.error("apiParseMarkdown error: \(error.localizedDescription)") + return nil + } + } + + private func isSimplexLink(_ link: String) -> Bool { + link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") + } + + private func cancelPreview() { + if let uri = linkPreview?.uri.absoluteString { + cancelledLinks.insert(uri) + } + linkPreview = nil + } + + private func loadLinkPreview(_ url: URL) { + if pendingLinkUrl == url { getLinkPreview(url: url) { lp in if pendingLinkUrl == url { linkPreview = lp @@ -118,7 +121,7 @@ struct ComposeView: View { } } - func resetLinkPreview() { + private func resetLinkPreview() { linkUrl = nil prevLinkUrl = nil pendingLinkUrl = nil diff --git a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift index 00f2806828..09db349295 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift @@ -22,14 +22,18 @@ struct ChatItemLinkView: View { } VStack(alignment: .leading, spacing: 6) { Text(linkPreview.title) - .lineLimit(2) - .padding(.horizontal, 12) + .lineLimit(3) +// if linkPreview.description != "" { +// Text(linkPreview.description) +// .font(.subheadline) +// .lineLimit(12) +// } Text(linkPreview.uri.absoluteString) .font(.caption) .lineLimit(1) .foregroundColor(.secondary) - .padding(.horizontal, 12) } + .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift index 7ed4fde77f..1a9c446499 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -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, maxSize: 14000), + let resized = resizeImageToDataSize(image, maxDataSize: 14000), let title = metadata.title, let uri = metadata.originalURL { linkPreview = LinkPreview(uri: uri, title: title, image: resized) diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 51d72a0da3..7fa0bc722b 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -17,47 +17,48 @@ func dropImagePrefix(_ s: String) -> String { dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") } -func resizeAndCrop(_ image: UIImage, to newSize: CGSize) -> UIImage { +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1.0 format.opaque = true - return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in - let size = image.size - let hScale = newSize.height / size.height - let vScale = newSize.width / size.width - let scale = max(hScale, vScale) // scaleToFill - let resizeSize = CGSize(width: size.width * scale, height: size.height * scale) - var middle = CGPoint.zero - if resizeSize.width > newSize.width { - middle.x -= (resizeSize.width - newSize.width) / 2 - } else if resizeSize.height > newSize.height { - middle.y -= (resizeSize.height - newSize.height) / 2 - } - image.draw(in: CGRect(origin: middle, size: resizeSize)) + return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in + image.draw(in: drawIn) } } func cropToSquare(_ image: UIImage) -> UIImage { - let side = min(image.size.width, image.size.height) - return resizeAndCrop(image, to: CGSize(width: side, height: side)) + 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, maxSize: Int) -> String? { - let size = image.size - var imageStr = compressImage(image) - var resized = image - var ratio: CGFloat = 1 - var dataSize = imageStr?.count ?? 0 - logger.debug("resizeImageToDataSize: initial size \(String(describing: size)), data size \(dataSize)") - while dataSize != 0 && dataSize > maxSize { - ratio *= sqrt(CGFloat(dataSize / maxSize) * 1.2) - resized = resizeAndCrop(resized, to: CGSize(width: size.width / ratio, height: size.height / ratio)) - imageStr = compressImage(resized) - dataSize = imageStr?.count ?? 0 - logger.debug("resizeImageToDataSize: ratio \(ratio)") + +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 resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? { + var img = image + var str = compressImage(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) + dataSize = str?.count ?? 0 } - logger.debug("resizeImageToDataSize: final size \(String(describing: resized.size)), data size \(dataSize)") - return imageStr + logger.debug("resizeImageToDataSize final \(dataSize)") + return str } func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index a83cac3a96..8c38681b3c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -99,7 +99,7 @@ struct UserProfile: View { } .onChange(of: chosenImage) { image in if let image = image { - profile.image = resizeImageToDataSize(cropToSquare(image), maxSize: 12500) + profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500) } else { profile.image = nil }