Add expiration subtitle to Delete option in message context menu

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

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

@ -5,9 +5,15 @@ import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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 network.loki.messenger.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -52,13 +58,9 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
val item: ActionItem, val item: ActionItem,
val displayType: DisplayType val displayType: DisplayType
) : MappingModel<DisplayItem> { ) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean { override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean { override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem
return this == newItem
}
} }
private enum class DisplayType { private enum class DisplayType {
@ -69,6 +71,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
itemView: View, itemView: View,
private val onItemClick: () -> Unit, private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) { ) : MappingViewHolder<DisplayItem>(itemView) {
private var subtitleJob: Job? = null
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon) val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.context_menu_item_title) val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle) 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) context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
} }
itemView.contentDescription = model.item.contentDescription model.item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
title.text = model.item.title title.setText(model.item.title)
subtitle.text = model.item.subtitle subtitle.isGone = true
subtitle.isVisible = model.item.subtitle != null model.item.subtitle?.let {
startSubtitleJob(subtitle, it)
}
itemView.setOnClickListener { itemView.setOnClickListener {
model.item.action.run() model.item.action.run()
onItemClick() onItemClick()
} }
when (model.displayType) { when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top) DisplayType.TOP -> R.drawable.context_menu_item_background_top
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom) DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle) DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only) 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.core.view.doOnLayout
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.components.emoji.EmojiImageView 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.AnimationCompleteListener
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale 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.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
class ConversationReactionOverlay : FrameLayout { class ConversationReactionOverlay : FrameLayout {
private val emojiViewGlobalRect = Rect() private val emojiViewGlobalRect = Rect()
@ -501,54 +507,51 @@ class ConversationReactionOverlay : FrameLayout {
?: return emptyList() ?: return emptyList()
val userPublicKey = getLocalNumber(context)!! val userPublicKey = getLocalNumber(context)!!
// Select message // Select message
items += ActionItem(R.attr.menu_select_icon, context.resources.getString(R.string.conversation_context__menu_select), { handleActionItemClicked(Action.SELECT) }, items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
context.resources.getString(R.string.AccessibilityId_select))
// Reply // Reply
val canWrite = openGroup == null || openGroup.canWrite val canWrite = openGroup == null || openGroup.canWrite
if (canWrite && !message.isPending && !message.isFailed) { 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) }, items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
context.resources.getString(R.string.AccessibilityId_reply_message))
} }
// Copy message text // Copy message text
if (!containsControlMessage && hasText) { 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 // Copy Session ID
if (recipient.isGroupRecipient && !recipient.isOpenGroupRecipient && message.recipient.address.toString() != userPublicKey) { 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 // Delete message
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) { if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem( val subtitle = { message.takeIf { it.expireStarted > 0 }
R.attr.menu_trash_icon, ?.run { expiresIn - (SnodeAPI.nowWithOffset - expireStarted) }
context.resources.getString(R.string.delete), ?.coerceAtLeast(0L)
{ handleActionItemClicked(Action.DELETE) }, ?.milliseconds
context.resources.getString(R.string.AccessibilityId_delete_message), ?.to2partString()
message.takeIf { it.expireStarted > 0 }?.run { expiresIn.milliseconds }?.let { "Auto-deletes in $it" } ?.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 // Ban user
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { 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 // Ban and delete all
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { 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 // 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 // Resend
if (message.isFailed) { 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 // Resync
if (message.isSyncFailed) { 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 // Save media
if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) { 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) }, items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment)
context.resources.getString(R.string.AccessibilityId_save_attachment))
} }
backgroundView.visibility = VISIBLE backgroundView.visibility = VISIBLE
foregroundView.visibility = VISIBLE foregroundView.visibility = VISIBLE
@ -585,16 +588,14 @@ class ConversationReactionOverlay : FrameLayout {
revealAnimatorSet.playTogether(reveals) revealAnimatorSet.playTogether(reveals)
} }
private fun newHideAnimatorSet(): AnimatorSet { private fun newHideAnimatorSet() = AnimatorSet().apply {
val set = AnimatorSet() addListener(object : AnimationCompleteListener() {
set.addListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
visibility = GONE visibility = GONE
} }
}) })
set.interpolator = INTERPOLATOR interpolator = INTERPOLATOR
set.playTogether(newHideAnimators()) playTogether(newHideAnimators())
return set
} }
private fun newHideAnimators(): List<Animator> { private fun newHideAnimators(): List<Animator> {
@ -644,28 +645,19 @@ class ConversationReactionOverlay : FrameLayout {
fun onActionSelected(action: Action) fun onActionSelected(action: Action)
} }
private class Boundary { private class Boundary(private var min: Float = 0f, private var max: Float = 0f) {
private var min = 0f
private var max = 0f
internal constructor()
internal constructor(min: Float, max: Float) {
update(min, max)
}
fun update(min: Float, max: Float) { fun update(min: Float, max: Float) {
this.min = min this.min = min
this.max = max this.max = max
} }
operator fun contains(value: Float): Boolean { operator fun contains(value: Float) = if (min < max) {
return if (min < max) {
min < value && max > value min < value && max > value
} else { } else {
min > value && max < value min > value && max < value
} }
} }
}
private enum class OverlayState { private enum class OverlayState {
HIDDEN, HIDDEN,
@ -694,3 +686,7 @@ class ConversationReactionOverlay : FrameLayout {
private val INTERPOLATOR: Interpolator = DecelerateInterpolator() 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 <TextView
android:id="@+id/context_menu_item_subtitle" 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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" 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="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="unread_marker">Unread Messages</string>
<string name="auto_deletes_in">Auto-deletes in %1$s</string>
</resources> </resources>

Loading…
Cancel
Save