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>
This commit is contained in:
IanRDavies
2022-04-11 09:39:04 +01:00
committed by GitHub
parent 02d21145b2
commit 1b930e717a
16 changed files with 467 additions and 139 deletions

View File

@@ -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'

File diff suppressed because one or more lines are too long

View File

@@ -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<FormattedText>? {
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<FormattedText>? = 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()

View File

@@ -43,7 +43,15 @@ fun TerminalLayout(terminalItems: List<TerminalItem>, 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(

View File

@@ -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<ChatItem?>(null) }
val editingItem = remember { mutableStateOf<ChatItem?>(null) }
val linkPreview = remember { mutableStateOf<LinkPreview?>(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<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
parseMarkdown: (String) -> List<FormattedText>?
) {
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 }
)
}
}

View File

@@ -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<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit
resetMessage: () -> Unit,
parseMarkdown: (String) -> List<FormattedText>?
) {
val cancelledLinks = remember { mutableSetOf<String>() }
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)
}
}

View File

@@ -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<String>, sendMessage: (String) -> Unit, editing: Boolean = false) {
fun SendMsgView(
msg: MutableState<String>,
linkPreview: MutableState<LinkPreview?>,
cancelledLinks: MutableSet<String>,
parseMarkdown: (String) -> List<FormattedText>?,
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<String?>(null) }
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
val pendingLinkUrl = remember { mutableStateOf<String?>(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<LinkPreview?>(null) },
cancelledLinks = mutableSetOf(),
parseMarkdown = { null },
sendMessage = { msg -> println(msg) }
)
}
@@ -115,7 +180,10 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme {
SendMsgView(
msg = remember { mutableStateOf("") },
linkPreview = remember {mutableStateOf<LinkPreview?>(null) },
cancelledLinks = mutableSetOf(),
sendMessage = { msg -> println(msg) },
parseMarkdown = { null },
editing = true
)
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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 ->

View File

@@ -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) { -> }
}
}

View File

@@ -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'
}