Displaying image (or video preview) in chat bubble if alone

This commit is contained in:
Sylvain Berfini 2023-10-31 16:54:51 +01:00
parent 59c7140ce6
commit 7f739a4bc1
7 changed files with 208 additions and 98 deletions

View file

@ -24,7 +24,6 @@ import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.text.set
import androidx.lifecycle.MutableLiveData
import java.util.regex.Pattern
import org.linphone.LinphoneApplication.Companion.coreContext
@ -32,6 +31,7 @@ import org.linphone.core.Address
import org.linphone.core.ChatMessage
import org.linphone.core.ChatMessageListenerStub
import org.linphone.core.ChatMessageReaction
import org.linphone.core.Content
import org.linphone.core.tools.Log
import org.linphone.ui.main.contacts.model.ContactAvatarModel
import org.linphone.utils.Event
@ -65,6 +65,8 @@ class ChatMessageModel @WorkerThread constructor(
val text = MutableLiveData<Spannable>()
val bigImagePath = MutableLiveData<String>()
val timestamp = chatMessage.time
val time = TimestampUtils.toString(timestamp)
@ -103,88 +105,40 @@ class ChatMessageModel @WorkerThread constructor(
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
updateReactionsList()
var textFound = false
for (content in chatMessage.contents) {
var displayableContentFound = false
val contents = chatMessage.contents
for (content in contents) {
if (content.isText) {
val textContent = content.utf8Text.orEmpty().trim()
val spannableBuilder = SpannableStringBuilder(textContent)
// Check for mentions
val chatRoom = chatMessage.chatRoom
val matcher = Pattern.compile(MENTION_REGEXP).matcher(textContent)
while (matcher.find()) {
val start = matcher.start()
val end = matcher.end()
val source = textContent.subSequence(start + 1, end) // +1 to remove @
Log.i("$TAG Found mention [$source]")
// Find address matching username
val address = if (chatRoom.localAddress.username == source) {
Log.i("$TAG mention found in local address")
coreContext.core.accountList.find {
it.params.identityAddress?.username == source
}?.params?.identityAddress
} else if (chatRoom.peerAddress.username == source) {
Log.i("$TAG mention found in peer address")
chatRoom.peerAddress
} else {
Log.i("$TAG looking for mention in participants")
chatRoom.participants.find {
it.address.username == source
}?.address
}
// Find display name for address
if (address != null) {
val displayName = coreContext.contactsManager.findDisplayName(address)
computeTextContent(content)
displayableContentFound = true
} else {
if (content.isFile) {
val path = content.filePath ?: ""
if (path.isNotEmpty()) {
Log.i(
"$TAG Using display name [$displayName] instead of username [$source]"
"$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]"
)
spannableBuilder.replace(start, end, "@$displayName")
val span = PatternClickableSpan.StyledClickableSpan(
object :
SpannableClickedListener {
override fun onSpanClicked(text: String) {
Log.i("$TAG Clicked on [$text] span")
}
when (content.type) {
"image", "video" -> {
bigImagePath.postValue(path)
displayableContentFound = true
}
)
spannableBuilder.setSpan(
span,
start,
start + displayName.length + 1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
"audio" -> {
}
else -> {
}
}
} else {
Log.i("$TAG Content path is empty : have to download it first")
// TODO: download it
}
} else {
Log.i("$TAG Content is not a File")
}
// Add clickable span for SIP URIs
text.postValue(
PatternClickableSpan()
.add(
Pattern.compile(
SIP_URI_REGEXP
),
object : SpannableClickedListener {
@UiThread
override fun onSpanClicked(text: String) {
coreContext.postOnCoreThread {
Log.i("$TAG Clicked on SIP URI: $text")
val address = coreContext.core.interpretUrl(text)
if (address != null) {
coreContext.startCall(address)
} else {
Log.w("$TAG Failed to parse [$text] as SIP URI")
}
}
}
}
)
.build(spannableBuilder)
)
textFound = true
}
}
if (!textFound) {
if (!displayableContentFound) { // Temporary workaround to prevent empty bubbles
val describe = LinphoneUtils.getTextDescribingMessage(chatMessage)
val spannable = Spannable.Factory.getInstance().newSpannable(describe)
text.postValue(spannable)
@ -230,4 +184,82 @@ class ChatMessageModel @WorkerThread constructor(
Log.i("$TAG Reactions for message [$id] are [$reactionsList]")
reactions.postValue(reactionsList)
}
@WorkerThread
private fun computeTextContent(content: Content) {
val textContent = content.utf8Text.orEmpty().trim()
Log.i("$TAG Found text content [$textContent] for message [${chatMessage.messageId}]")
val spannableBuilder = SpannableStringBuilder(textContent)
// Check for mentions
val chatRoom = chatMessage.chatRoom
val matcher = Pattern.compile(MENTION_REGEXP).matcher(textContent)
while (matcher.find()) {
val start = matcher.start()
val end = matcher.end()
val source = textContent.subSequence(start + 1, end) // +1 to remove @
Log.i("$TAG Found mention [$source]")
// Find address matching username
val address = if (chatRoom.localAddress.username == source) {
coreContext.core.accountList.find {
it.params.identityAddress?.username == source
}?.params?.identityAddress
} else if (chatRoom.peerAddress.username == source) {
chatRoom.peerAddress
} else {
chatRoom.participants.find {
it.address.username == source
}?.address
}
// Find display name for address
if (address != null) {
val displayName = coreContext.contactsManager.findDisplayName(address)
Log.i(
"$TAG Using display name [$displayName] instead of username [$source]"
)
spannableBuilder.replace(start, end, "@$displayName")
val span = PatternClickableSpan.StyledClickableSpan(
object :
SpannableClickedListener {
override fun onSpanClicked(text: String) {
Log.i("$TAG Clicked on [$text] span")
// TODO
}
}
)
spannableBuilder.setSpan(
span,
start,
start + displayName.length + 1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
// Add clickable span for SIP URIs
text.postValue(
PatternClickableSpan()
.add(
Pattern.compile(
SIP_URI_REGEXP
),
object : SpannableClickedListener {
@UiThread
override fun onSpanClicked(text: String) {
coreContext.postOnCoreThread {
Log.i("$TAG Clicked on SIP URI: $text")
val address = coreContext.core.interpretUrl(text)
if (address != null) {
coreContext.startCall(address)
} else {
Log.w("$TAG Failed to parse [$text] as SIP URI")
}
}
}
}
)
.build(spannableBuilder)
)
}
}

View file

@ -51,6 +51,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.dispose
import coil.load
import coil.request.videoFrameMillis
import com.google.android.material.imageview.ShapeableImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@ -205,6 +206,40 @@ fun AppCompatTextView.setColor(@ColorRes color: Int) {
setTextColor(AppUtils.getColor(color))
}
@UiThread
@BindingAdapter("coilBubble")
fun ImageView.loadImageForChatBubble(file: String?) {
if (!file.isNullOrEmpty()) {
if (FileUtils.isExtensionVideo(file)) {
load(file) {
videoFrameMillis(0)
listener(
onError = { _, result ->
Log.e(
"[Data Binding] [Coil] Error getting preview picture from video? [$file]: ${result.throwable}"
)
visibility = View.GONE
},
onSuccess = { _, _ ->
// TODO: Display "play" button above video preview
}
)
}
} else {
load(file) {
listener(
onError = { _, result ->
Log.e(
"[Data Binding] [Coil] Error getting picture from file [$file]: ${result.throwable}"
)
visibility = View.GONE
}
)
}
}
}
}
@UiThread
@BindingAdapter("coil")
fun ShapeableImageView.loadCircleFileWithCoil(file: String?) {

View file

@ -253,6 +253,13 @@ class FileUtils {
return name
}
@AnyThread
fun isExtensionVideo(path: String): Boolean {
val extension = getExtensionFromFileName(path)
val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
return getMimeType(type) == MimeType.Video
}
@AnyThread
private fun getExtensionFromFileName(fileName: String): String {
var extension = MimeTypeMap.getFileExtensionFromUrl(fileName)

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<variable
name="model"
type="org.linphone.ui.main.chat.model.ChatMessageModel" />
</data>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/big_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:src="@drawable/smiley"
android:visibility="@{model.bigImagePath.length() > 0 ? View.VISIBLE : View.GONE}"
android:maxHeight="@dimen/chat_bubble_big_image_max_size"
coilBubble="@{model.bigImagePath}"/>
<org.linphone.ui.main.chat.view.ChatBubbleTextView
style="@style/default_text_style"
android:id="@+id/text_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{model.text, default=`Lorem ipsum dolor sit amet`}"
android:textSize="14sp"
android:textColor="@color/gray_main2_700"
android:gravity="center_vertical|start"
android:layout_below="@id/big_image"
android:visibility="@{model.text.length() > 0 ? View.VISIBLE : View.GONE}"/>
</RelativeLayout>
</layout>

View file

@ -108,7 +108,7 @@
android:layout_height="wrap_content"
app:barrierDirection="end"
app:barrierMargin="10dp"
app:constraint_referenced_ids="delivery_status, text_message" />
app:constraint_referenced_ids="delivery_status, contents" />
<ImageView
android:id="@+id/background"
@ -121,24 +121,21 @@
app:layout_constraintTop_toBottomOf="@id/reply"
app:layout_constraintBottom_toBottomOf="@id/date_time"/>
<org.linphone.ui.main.chat.view.ChatBubbleTextView
style="@style/default_text_style"
android:id="@+id/text_message"
<include
android:id="@+id/contents"
layout="@layout/chat_bubble_content"
model="@{model}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="@dimen/chat_bubble_text_padding_with_bubble"
android:layout_marginEnd="16dp"
android:paddingBottom="@{model.groupedWithNextOne ? @dimen/chat_bubble_text_padding_with_status : @dimen/chat_bubble_text_padding_with_bubble, default=@dimen/chat_bubble_text_padding_with_status}"
android:text="@{model.text, default=`Lorem ipsum dolor sit amet`}"
android:textSize="14sp"
android:textColor="@color/gray_main2_700"
android:gravity="center_vertical|start"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintTop_toBottomOf="@id/reply"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintEnd_toEndOf="parent"/>
app:layout_constraintEnd_toEndOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
@ -151,8 +148,8 @@
android:text="@{model.time, default=`13:40`}"
android:textSize="12sp"
android:visibility="@{model.isGroupedWithNextOne ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toBottomOf="@id/text_message"
app:layout_constraintStart_toStartOf="@id/text_message"/>
app:layout_constraintTop_toBottomOf="@id/contents"
app:layout_constraintStart_toStartOf="@id/contents"/>
<ImageView
style="@style/default_text_style_300"

View file

@ -38,7 +38,7 @@
android:layout_height="wrap_content"
app:barrierDirection="start"
app:barrierMargin="-10dp"
app:constraint_referenced_ids="date_time, text_message" />
app:constraint_referenced_ids="date_time, contents" />
<View
android:id="@+id/reply_background"
@ -82,24 +82,21 @@
app:layout_constraintTop_toBottomOf="@id/reply"
app:layout_constraintBottom_toBottomOf="@id/date_time"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/text_message"
<include
android:id="@+id/contents"
layout="@layout/chat_bubble_content"
model="@{model}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="@dimen/chat_bubble_text_padding_with_bubble"
android:layout_marginEnd="10dp"
android:layout_marginEnd="@dimen/chat_bubble_text_padding_with_bubble"
android:paddingBottom="@{model.groupedWithNextOne ? @dimen/chat_bubble_text_padding_with_status : @dimen/chat_bubble_text_padding_with_bubble, default=@dimen/chat_bubble_text_padding_with_status}"
android:text="@{model.text, default=`Hi!`}"
android:textSize="14sp"
android:textColor="@color/gray_main2_700"
android:gravity="center_vertical|end"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/reply"
app:layout_constraintEnd_toEndOf="parent"/>
app:layout_constraintEnd_toEndOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
@ -113,7 +110,7 @@
android:text="@{model.time, default=`13:40`}"
android:textSize="12sp"
android:visibility="@{model.isGroupedWithNextOne ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toBottomOf="@id/text_message"
app:layout_constraintTop_toBottomOf="@id/contents"
app:layout_constraintEnd_toStartOf="@id/delivery_status"/>
<ImageView
@ -125,7 +122,7 @@
android:src="@{model.statusIcon, default=@drawable/checks}"
android:visibility="@{model.isGroupedWithNextOne ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toTopOf="@id/date_time"
app:layout_constraintEnd_toEndOf="@id/text_message"
app:layout_constraintEnd_toEndOf="@id/contents"
app:layout_constraintBottom_toBottomOf="@id/date_time"
app:tint="@color/orange_main_500" />

View file

@ -66,6 +66,7 @@
<dimen name="chat_bubble_text_padding_with_bubble">12dp</dimen>
<dimen name="chat_bubble_text_padding_with_status">5dp</dimen>
<dimen name="chat_bubble_long_press_emoji_reaction_size">30sp</dimen>
<dimen name="chat_bubble_big_image_max_size">200dp</dimen>
<dimen name="chat_room_emoji_picker_height">290dp</dimen>
<dimen name="chat_bubble_emoji_picker_height">425dp</dimen>