diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt index 16b0dc914f..866962b38e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt index 7cedffb044..a61bda07be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -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 { - 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(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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index ecb5217fed..98c06d91bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -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 { @@ -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() } -} \ No newline at end of file +} + +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(" ") diff --git a/app/src/main/res/layout/context_menu_item.xml b/app/src/main/res/layout/context_menu_item.xml index 12a049162d..83d43d82d7 100644 --- a/app/src/main/res/layout/context_menu_item.xml +++ b/app/src/main/res/layout/context_menu_item.xml @@ -34,7 +34,7 @@ You have no messages from %s.\nSend a message to start the conversation! Unread Messages + Auto-deletes in %1$s