Make url clickable on the preview of event in the bottom sheet

This commit is contained in:
Benoit Marty 2019-12-03 16:02:07 +01:00
parent 71de8fdad3
commit 6d7f2670df
9 changed files with 93 additions and 49 deletions

View File

@ -25,6 +25,8 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess
/** /**
@ -45,10 +47,13 @@ abstract class BottomSheetItemMessagePreview : VectorEpoxyModel<BottomSheetItemM
lateinit var body: CharSequence lateinit var body: CharSequence
@EpoxyAttribute @EpoxyAttribute
var time: CharSequence? = null var time: CharSequence? = null
@EpoxyAttribute
var urlClickCallback: TimelineEventController.UrlClickCallback? = null
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
avatarRenderer.render(avatarUrl, senderId, senderName, holder.avatar) avatarRenderer.render(avatarUrl, senderId, senderName, holder.avatar)
holder.sender.setTextOrHide(senderName) holder.sender.setTextOrHide(senderName)
holder.body.movementMethod = createLinkMovementMethod(urlClickCallback)
holder.body.text = body holder.body.text = body
body.findPillsAndProcess { it.bind(holder.body) } body.findPillsAndProcess { it.bind(holder.body) }
holder.timestamp.setTextOrHide(time) holder.timestamp.setTextOrHide(time)

View File

@ -1163,6 +1163,12 @@ class RoomDetailFragment @Inject constructor(
is EventSharedAction.IgnoreUser -> { is EventSharedAction.IgnoreUser -> {
roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(action.senderId)) roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(action.senderId))
} }
is EventSharedAction.OnUrlClicked -> {
onUrlClicked(action.url)
}
is EventSharedAction.OnUrlLongClicked -> {
onUrlLongClicked(action.url)
}
else -> { else -> {
Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show() Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show()
} }

View File

@ -88,4 +88,12 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, @DrawableRes val ic
data class ViewEditHistory(val messageInformationData: MessageInformationData) : data class ViewEditHistory(val messageInformationData: MessageInformationData) :
EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history) EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history)
// An url in the event preview has been clicked
data class OnUrlClicked(val url: String) :
EventSharedAction(0, 0)
// An url in the event preview has been long clicked
data class OnUrlLongClicked(val url: String) :
EventSharedAction(0, 0)
} }

View File

@ -68,6 +68,18 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment(), Message
messageActionsEpoxyController.listener = this messageActionsEpoxyController.listener = this
} }
override fun onUrlClicked(url: String): Boolean {
sharedActionViewModel.post(EventSharedAction.OnUrlClicked(url))
// Always consume
return true
}
override fun onUrlLongClicked(url: String): Boolean {
sharedActionViewModel.post(EventSharedAction.OnUrlLongClicked(url))
// Always consume
return true
}
override fun didSelectMenuAction(eventAction: EventSharedAction) { override fun didSelectMenuAction(eventAction: EventSharedAction) {
if (eventAction is EventSharedAction.ReportContent) { if (eventAction is EventSharedAction.ReportContent) {
// Toggle report menu // Toggle report menu

View File

@ -23,6 +23,8 @@ import im.vector.riotx.R
import im.vector.riotx.core.epoxy.bottomsheet.* import im.vector.riotx.core.epoxy.bottomsheet.*
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.tools.linkify
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -44,7 +46,8 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
avatarUrl(state.informationData.avatarUrl ?: "") avatarUrl(state.informationData.avatarUrl ?: "")
senderId(state.informationData.senderId) senderId(state.informationData.senderId)
senderName(state.senderName()) senderName(state.senderName())
body(body) urlClickCallback(listener)
body(body.linkify(listener))
time(state.time()) time(state.time())
} }
} }
@ -127,7 +130,7 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
} }
} }
interface MessageActionsEpoxyControllerListener { interface MessageActionsEpoxyControllerListener : TimelineEventController.UrlClickCallback {
fun didSelectMenuAction(eventAction: EventSharedAction) fun didSelectMenuAction(eventAction: EventSharedAction)
} }
} }

View File

@ -24,8 +24,6 @@ import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.view.View import android.view.View
import dagger.Lazy import dagger.Lazy
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.RelationType
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.model.message.*
@ -35,7 +33,6 @@ import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.linkify.VectorLinkify
import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
@ -45,8 +42,9 @@ import im.vector.riotx.core.utils.isLocalFile
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.helper.*
import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.home.room.detail.timeline.tools.linkify
import im.vector.riotx.features.html.CodeVisitor import im.vector.riotx.features.html.CodeVisitor
import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer import im.vector.riotx.features.media.VideoContentRenderer
import me.gujun.android.span.span import me.gujun.android.span.span
@ -195,8 +193,7 @@ class MessageItemFactory @Inject constructor(
val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val thumbnailData = ImageContentRenderer.Data( val thumbnailData = ImageContentRenderer.Data(
filename = messageContent.body, filename = messageContent.body,
url = messageContent.videoInfo?.thumbnailFile?.url url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height, height = messageContent.videoInfo?.height,
maxHeight = maxHeight, maxHeight = maxHeight,
@ -258,7 +255,7 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? { attributes: AbsMessageItem.Attributes): MessageTextItem? {
val linkifiedBody = linkifyBody(body, callback) val linkifiedBody = body.linkify(callback)
return MessageTextItem_().apply { return MessageTextItem_().apply {
if (informationData.hasBeenEdited) { if (informationData.hasBeenEdited) {
@ -344,7 +341,7 @@ class MessageItemFactory @Inject constructor(
textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary)
textStyle = "italic" textStyle = "italic"
} }
linkifyBody(formattedBody, callback) formattedBody.linkify(callback)
} }
return MessageTextItem_() return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
@ -361,7 +358,7 @@ class MessageItemFactory @Inject constructor(
attributes: AbsMessageItem.Attributes): MessageTextItem? { attributes: AbsMessageItem.Attributes): MessageTextItem? {
val message = messageContent.body.let { val message = messageContent.body.let {
val formattedBody = "* ${informationData.memberName} $it" val formattedBody = "* ${informationData.memberName} $it"
linkifyBody(formattedBody, callback) formattedBody.linkify(callback)
} }
return MessageTextItem_() return MessageTextItem_()
.apply { .apply {
@ -386,17 +383,6 @@ class MessageItemFactory @Inject constructor(
.highlighted(highlight) .highlighted(highlight)
} }
private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {
val spannable = SpannableStringBuilder(body)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) {
callback?.onUrlClicked(url)
}
})
VectorLinkify.addLinks(spannable, true)
return spannable
}
companion object { companion object {
private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5 private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5
} }

View File

@ -16,17 +16,15 @@
package im.vector.riotx.features.home.room.detail.timeline.item package im.vector.riotx.features.home.room.detail.timeline.item
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat import androidx.core.text.PrecomputedTextCompat
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.utils.isValidUrl
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess
import me.saket.bettermovementmethod.BetterLinkMovementMethod
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() { abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@ -40,28 +38,9 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var urlClickCallback: TimelineEventController.UrlClickCallback? = null var urlClickCallback: TimelineEventController.UrlClickCallback? = null
// Better link movement methods fixes the issue when
// long pressing to open the context menu on a TextView also triggers an autoLink click.
private val mvmtMethod = BetterLinkMovementMethod.newInstance().also {
it.setOnLinkClickListener { _, url ->
// Return false to let android manage the click on the link, or true if the link is handled by the application
url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true
}
// We need also to fix the case when long click on link will trigger long click on cell
it.setOnLinkLongClickListener { tv, url ->
// Long clicks are handled by parent, return true to block android to do something with url
if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) {
tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
true
} else {
false
}
}
}
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.messageView.movementMethod = mvmtMethod holder.messageView.movementMethod = createLinkMovementMethod(urlClickCallback)
if (useBigFont) { if (useBigFont) {
holder.messageView.textSize = 44F holder.messageView.textSize = 44F
} else { } else {

View File

@ -16,12 +16,20 @@
package im.vector.riotx.features.home.room.detail.timeline.tools package im.vector.riotx.features.home.room.detail.timeline.tools
import android.text.SpannableStringBuilder
import android.view.MotionEvent
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.riotx.core.linkify.VectorLinkify
import im.vector.riotx.core.utils.isValidUrl
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.saket.bettermovementmethod.BetterLinkMovementMethod
fun CharSequence.findPillsAndProcess(processBlock: (PillImageSpan) -> Unit) { fun CharSequence.findPillsAndProcess(processBlock: (PillImageSpan) -> Unit) {
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
@ -32,3 +40,37 @@ fun CharSequence.findPillsAndProcess(processBlock: (PillImageSpan) -> Unit) {
}.forEach { processBlock(it) } }.forEach { processBlock(it) }
} }
} }
fun CharSequence.linkify(callback: TimelineEventController.UrlClickCallback?): CharSequence {
val spannable = SpannableStringBuilder(this)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) {
callback?.onUrlClicked(url)
}
})
VectorLinkify.addLinks(spannable, true)
return spannable
}
// Better link movement methods fixes the issue when
// long pressing to open the context menu on a TextView also triggers an autoLink click.
fun createLinkMovementMethod(urlClickCallback: TimelineEventController.UrlClickCallback?): BetterLinkMovementMethod {
return BetterLinkMovementMethod.newInstance()
.apply {
setOnLinkClickListener { _, url ->
// Return false to let android manage the click on the link, or true if the link is handled by the application
url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true
}
// We need also to fix the case when long click on link will trigger long click on cell
setOnLinkLongClickListener { tv, url ->
// Long clicks are handled by parent, return true to block android to do something with url
if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) {
tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
true
} else {
false
}
}
}
}

View File

@ -266,6 +266,7 @@
<item name="android:textColorSecondary">@color/riot_secondary_text_color_dark</item> <item name="android:textColorSecondary">@color/riot_secondary_text_color_dark</item>
<!-- Default color for text View --> <!-- Default color for text View -->
<item name="android:textColorTertiary">@color/riot_tertiary_text_color_dark</item> <item name="android:textColorTertiary">@color/riot_tertiary_text_color_dark</item>
<item name="android:textColorLink">@color/riotx_links</item>
</style> </style>
<style name="Vector.BottomSheet.Light" parent="Theme.Design.Light.BottomSheetDialog"> <style name="Vector.BottomSheet.Light" parent="Theme.Design.Light.BottomSheetDialog">
@ -273,6 +274,7 @@
<item name="android:textColorSecondary">@color/riot_secondary_text_color_light</item> <item name="android:textColorSecondary">@color/riot_secondary_text_color_light</item>
<!-- Default color for text View --> <!-- Default color for text View -->
<item name="android:textColorTertiary">@color/riot_tertiary_text_color_light</item> <item name="android:textColorTertiary">@color/riot_tertiary_text_color_light</item>
<item name="android:textColorLink">@color/riotx_links</item>
</style> </style>
<style name="Vector.BottomSheet.Status" parent="Theme.Design.Light.BottomSheetDialog"> <style name="Vector.BottomSheet.Status" parent="Theme.Design.Light.BottomSheetDialog">
@ -280,6 +282,7 @@
<item name="android:textColorSecondary">@color/riot_secondary_text_color_status</item> <item name="android:textColorSecondary">@color/riot_secondary_text_color_status</item>
<!-- Default color for text View --> <!-- Default color for text View -->
<item name="android:textColorTertiary">@color/riot_tertiary_text_color_status</item> <item name="android:textColorTertiary">@color/riot_tertiary_text_color_status</item>
<item name="android:textColorLink">@color/link_color_status</item>
</style> </style>