Add expiration subtitle to Delete option in message context menu

pull/1313/head
Andrew 4 months ago
parent b8aa46912a
commit b56c3bd6c5

@ -5,10 +5,10 @@ import androidx.annotation.AttrRes
/**
* Represents an action to be rendered
*/
data class ActionItem @JvmOverloads constructor(
data class ActionItem(
@AttrRes val iconRes: Int,
val title: CharSequence,
val title: Int,
val action: Runnable,
val contentDescription: String? = null,
val subtitle: String? = null
val contentDescription: Int? = null,
val subtitle: (() -> CharSequence?)? = null
)

@ -5,9 +5,15 @@ import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -52,13 +58,9 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
val item: ActionItem,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem
}
private enum class DisplayType {
@ -69,6 +71,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
itemView: View,
private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) {
private var subtitleJob: Job? = null
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle)
@ -79,21 +82,45 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
}
itemView.contentDescription = model.item.contentDescription
title.text = model.item.title
subtitle.text = model.item.subtitle
subtitle.isVisible = model.item.subtitle != null
model.item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
title.setText(model.item.title)
subtitle.isGone = true
model.item.subtitle?.let {
startSubtitleJob(subtitle, it)
}
itemView.setOnClickListener {
model.item.action.run()
onItemClick()
}
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only)
DisplayType.TOP -> R.drawable.context_menu_item_background_top
DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom
DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle
DisplayType.ONLY -> R.drawable.context_menu_item_background_only
}.let(itemView::setBackgroundResource)
}
private fun startSubtitleJob(textView: TextView, getSubtitle: () -> CharSequence?) {
fun updateText() = getSubtitle().let {
textView.isGone = it == null
textView.text = it
}
updateText()
subtitleJob?.cancel()
subtitleJob = CoroutineScope(Dispatchers.Main).launch {
while (true) {
updateText()
delay(200)
}
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// naive job cancellation, will break if many items are added to context menu.
subtitleJob?.cancel()
}
}
}

@ -23,6 +23,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
@ -37,7 +38,12 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.util.AnimationCompleteListener
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
class ConversationReactionOverlay : FrameLayout {
private val emojiViewGlobalRect = Rect()
@ -501,54 +507,51 @@ class ConversationReactionOverlay : FrameLayout {
?: return emptyList()
val userPublicKey = getLocalNumber(context)!!
// Select message
items += ActionItem(R.attr.menu_select_icon, context.resources.getString(R.string.conversation_context__menu_select), { handleActionItemClicked(Action.SELECT) },
context.resources.getString(R.string.AccessibilityId_select))
items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
// Reply
val canWrite = openGroup == null || openGroup.canWrite
if (canWrite && !message.isPending && !message.isFailed) {
items += ActionItem(R.attr.menu_reply_icon, context.resources.getString(R.string.conversation_context__menu_reply), { handleActionItemClicked(Action.REPLY) },
context.resources.getString(R.string.AccessibilityId_reply_message))
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
}
// Copy message text
if (!containsControlMessage && hasText) {
items += ActionItem(R.attr.menu_copy_icon, context.resources.getString(R.string.copy), { handleActionItemClicked(Action.COPY_MESSAGE) })
items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) })
}
// Copy Session ID
if (recipient.isGroupRecipient && !recipient.isOpenGroupRecipient && message.recipient.address.toString() != userPublicKey) {
items += ActionItem(R.attr.menu_copy_icon, context.resources.getString(R.string.activity_conversation_menu_copy_session_id), { handleActionItemClicked(Action.COPY_SESSION_ID) })
items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) })
}
// Delete message
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(
R.attr.menu_trash_icon,
context.resources.getString(R.string.delete),
{ handleActionItemClicked(Action.DELETE) },
context.resources.getString(R.string.AccessibilityId_delete_message),
message.takeIf { it.expireStarted > 0 }?.run { expiresIn.milliseconds }?.let { "Auto-deletes in $it" }
)
val subtitle = { message.takeIf { it.expireStarted > 0 }
?.run { expiresIn - (SnodeAPI.nowWithOffset - expireStarted) }
?.coerceAtLeast(0L)
?.milliseconds
?.to2partString()
?.let { context.getString(R.string.auto_deletes_in, it) } }
items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, subtitle)
}
// Ban user
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_block_icon, context.resources.getString(R.string.conversation_context__menu_ban_user), { handleActionItemClicked(Action.BAN_USER) })
items += ActionItem(R.attr.menu_block_icon, R.string.conversation_context__menu_ban_user, { handleActionItemClicked(Action.BAN_USER) })
}
// Ban and delete all
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_trash_icon, context.resources.getString(R.string.conversation_context__menu_ban_and_delete_all), { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) })
items += ActionItem(R.attr.menu_trash_icon, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) })
}
// Message detail
items += ActionItem(R.attr.menu_info_icon, context.resources.getString(R.string.conversation_context__menu_message_details), { handleActionItemClicked(Action.VIEW_INFO) })
items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) })
// Resend
if (message.isFailed) {
items += ActionItem(R.attr.menu_reply_icon, context.resources.getString(R.string.conversation_context__menu_resend_message), { handleActionItemClicked(Action.RESEND) })
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) })
}
// Resync
if (message.isSyncFailed) {
items += ActionItem(R.attr.menu_reply_icon, context.resources.getString(R.string.conversation_context__menu_resync_message), { handleActionItemClicked(Action.RESYNC) })
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resync_message, { handleActionItemClicked(Action.RESYNC) })
}
// Save media
if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) {
items += ActionItem(R.attr.menu_save_icon, context.resources.getString(R.string.conversation_context_image__save_attachment), { handleActionItemClicked(Action.DOWNLOAD) },
context.resources.getString(R.string.AccessibilityId_save_attachment))
items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment)
}
backgroundView.visibility = VISIBLE
foregroundView.visibility = VISIBLE
@ -585,16 +588,14 @@ class ConversationReactionOverlay : FrameLayout {
revealAnimatorSet.playTogether(reveals)
}
private fun newHideAnimatorSet(): AnimatorSet {
val set = AnimatorSet()
set.addListener(object : AnimationCompleteListener() {
private fun newHideAnimatorSet() = AnimatorSet().apply {
addListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator) {
visibility = GONE
}
})
set.interpolator = INTERPOLATOR
set.playTogether(newHideAnimators())
return set
interpolator = INTERPOLATOR
playTogether(newHideAnimators())
}
private fun newHideAnimators(): List<Animator> {
@ -644,26 +645,17 @@ class ConversationReactionOverlay : FrameLayout {
fun onActionSelected(action: Action)
}
private class Boundary {
private var min = 0f
private var max = 0f
internal constructor()
internal constructor(min: Float, max: Float) {
update(min, max)
}
private class Boundary(private var min: Float = 0f, private var max: Float = 0f) {
fun update(min: Float, max: Float) {
this.min = min
this.max = max
}
operator fun contains(value: Float): Boolean {
return if (min < max) {
min < value && max > value
} else {
min > value && max < value
}
operator fun contains(value: Float) = if (min < max) {
min < value && max > value
} else {
min > value && max < value
}
}
@ -693,4 +685,8 @@ class ConversationReactionOverlay : FrameLayout {
const val LONG_PRESS_SCALE_FACTOR = 0.95f
private val INTERPOLATOR: Interpolator = DecelerateInterpolator()
}
}
}
private fun Duration.to2partString(): String? =
toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) }
.filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ")

@ -34,7 +34,7 @@
<TextView
android:id="@+id/context_menu_item_subtitle"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textSize="@dimen/tiny_font_size"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"

@ -1077,5 +1077,6 @@
<string name="activity_conversation_empty_state_default">You have no messages from <b>%s</b>.\nSend a message to start the conversation!</string>
<string name="unread_marker">Unread Messages</string>
<string name="auto_deletes_in">Auto-deletes in %1$s</string>
</resources>

Loading…
Cancel
Save