mirror of
https://github.com/oxen-io/session-android.git
synced 2025-04-28 19:40:46 +00:00
Add expiration subtitle to Delete option in message context menu
This commit is contained in:
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,26 +645,17 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -693,4 +685,8 @@ class ConversationReactionOverlay : FrameLayout {
|
|||||||
const val LONG_PRESS_SCALE_FACTOR = 0.95f
|
const val LONG_PRESS_SCALE_FACTOR = 0.95f
|
||||||
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…
x
Reference in New Issue
Block a user