Improve remote delete handling in group story threads.

This commit is contained in:
Alex Hart 2022-04-20 15:00:19 -03:00
parent 8b1552952c
commit 944c8530d8
8 changed files with 246 additions and 33 deletions

View file

@ -36,13 +36,22 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour
}
private fun readRowFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
return if (MmsSmsColumns.Types.isStoryReaction(record.type)) {
readReactionFromRecord(record)
} else {
readTextFromRecord(record)
return when {
record.isRemoteDelete -> readRemoteDeleteFromRecord(record)
MmsSmsColumns.Types.isStoryReaction(record.type) -> readReactionFromRecord(record)
else -> readTextFromRecord(record)
}
}
private fun readRemoteDeleteFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
return StoryGroupReplyItemData(
key = StoryGroupReplyItemData.Key.RemoteDelete(record.id),
sender = if (record.isOutgoing) Recipient.self() else record.individualRecipient.resolve(),
sentAtMillis = record.dateSent,
replyBody = StoryGroupReplyItemData.ReplyBody.RemoteDelete(record)
)
}
private fun readReactionFromRecord(record: MmsMessageRecord): StoryGroupReplyItemData {
return StoryGroupReplyItemData(
key = StoryGroupReplyItemData.Key.Reaction(record.id),

View file

@ -181,9 +181,6 @@ class StoryGroupReplyFragment :
requireContext(),
it.sender
),
onPrivateReplyClick = { model ->
requireListener<Callback>().onStartDirectReply(model.storyGroupReplyItemData.sender.id)
},
onCopyClick = { model ->
val clipData = ClipData.newPlainText(requireContext().getString(R.string.app_name), model.text.message.getDisplayBody(requireContext()))
ServiceUtil.getClipboardManager(requireContext()).setPrimaryClip(clipData)
@ -216,6 +213,25 @@ class StoryGroupReplyFragment :
)
)
}
is StoryGroupReplyItemData.ReplyBody.RemoteDelete -> {
customPref(
StoryGroupReplyItem.RemoteDeleteModel(
storyGroupReplyItemData = it,
remoteDelete = it.replyBody,
nameColor = colorizer.getIncomingGroupSenderColor(
requireContext(),
it.sender
),
onDeleteClick = { model ->
lifecycleDisposable += DeleteDialog.show(requireActivity(), setOf(model.remoteDelete.messageRecord)).subscribe { didDeleteThread ->
if (didDeleteThread) {
throw AssertionError("We should never end up deleting a Group Thread like this.")
}
}
},
)
)
}
}
}
}

View file

@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
@ -38,13 +39,13 @@ object StoryGroupReplyItem {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(TextModel::class.java, LayoutFactory(::TextViewHolder, R.layout.stories_group_text_reply_item))
mappingAdapter.registerFactory(ReactionModel::class.java, LayoutFactory(::ReactionViewHolder, R.layout.stories_group_reaction_reply_item))
mappingAdapter.registerFactory(RemoteDeleteModel::class.java, LayoutFactory(::RemoteDeleteViewHolder, R.layout.stories_group_remote_delete_item))
}
class TextModel(
val storyGroupReplyItemData: StoryGroupReplyItemData,
val text: StoryGroupReplyItemData.ReplyBody.Text,
@ColorInt val nameColor: Int,
val onPrivateReplyClick: (TextModel) -> Unit,
val onCopyClick: (TextModel) -> Unit,
val onDeleteClick: (TextModel) -> Unit,
val onMentionClick: (RecipientId) -> Unit
@ -74,6 +75,35 @@ object StoryGroupReplyItem {
}
}
class RemoteDeleteModel(
val storyGroupReplyItemData: StoryGroupReplyItemData,
val remoteDelete: StoryGroupReplyItemData.ReplyBody.RemoteDelete,
val onDeleteClick: (RemoteDeleteModel) -> Unit,
@ColorInt val nameColor: Int
) : MappingModel<RemoteDeleteModel> {
override fun areItemsTheSame(newItem: RemoteDeleteModel): Boolean {
return storyGroupReplyItemData.sender == newItem.storyGroupReplyItemData.sender &&
storyGroupReplyItemData.sentAtMillis == newItem.storyGroupReplyItemData.sentAtMillis
}
override fun areContentsTheSame(newItem: RemoteDeleteModel): Boolean {
return storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender) &&
nameColor == newItem.nameColor
}
override fun getChangePayload(newItem: RemoteDeleteModel): Any? {
return if (nameColor != newItem.nameColor &&
storyGroupReplyItemData == newItem.storyGroupReplyItemData &&
storyGroupReplyItemData.sender.hasSameContent(newItem.storyGroupReplyItemData.sender)
) {
NAME_COLOR_CHANGED
} else {
null
}
}
}
class ReactionModel(
val storyGroupReplyItemData: StoryGroupReplyItemData,
val reaction: StoryGroupReplyItemData.ReplyBody.Reaction,
@ -104,13 +134,12 @@ object StoryGroupReplyItem {
}
}
private class TextViewHolder(itemView: View) : MappingViewHolder<TextModel>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val body: EmojiTextView = itemView.findViewById(R.id.body)
private val date: TextView = itemView.findViewById(R.id.viewed_at)
private val dateBelow: TextView = itemView.findViewById(R.id.viewed_at_below)
private abstract class BaseViewHolder<T>(itemView: View) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
protected val name: FromTextView = itemView.findViewById(R.id.name)
protected val body: EmojiTextView = itemView.findViewById(R.id.body)
protected val date: TextView = itemView.findViewById(R.id.viewed_at)
protected val dateBelow: TextView = itemView.findViewById(R.id.viewed_at_below)
init {
body.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
@ -123,6 +152,9 @@ object StoryGroupReplyItem {
}
}
}
}
private class TextViewHolder(itemView: View) : BaseViewHolder<TextModel>(itemView) {
override fun bind(model: TextModel) {
itemView.setOnLongClickListener {
@ -179,6 +211,39 @@ object StoryGroupReplyItem {
}
}
private class RemoteDeleteViewHolder(itemView: View) : BaseViewHolder<RemoteDeleteModel>(itemView) {
override fun bind(model: RemoteDeleteModel) {
itemView.setOnLongClickListener {
displayContextMenu(model)
true
}
name.setTextColor(model.nameColor)
if (payload.contains(NAME_COLOR_CHANGED)) {
return
}
AvatarUtil.loadIconIntoImageView(model.storyGroupReplyItemData.sender, avatar, DimensionUnit.DP.toPixels(28f).toInt())
name.text = resolveName(context, model.storyGroupReplyItemData.sender)
date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
dateBelow.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.storyGroupReplyItemData.sentAtMillis)
}
private fun displayContextMenu(model: RemoteDeleteModel) {
itemView.isSelected = true
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.onDismiss { itemView.isSelected = false }
.show(
listOf(
ActionItem(R.drawable.ic_trash_24_solid_tinted, context.getString(R.string.StoryGroupReplyItem__delete)) { model.onDeleteClick(model) }
)
)
}
}
private class ReactionViewHolder(itemView: View) : MappingViewHolder<ReactionModel>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
private val name: FromTextView = itemView.findViewById(R.id.name)

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.stories.viewer.reply.group
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
data class StoryGroupReplyItemData(
@ -12,10 +13,12 @@ data class StoryGroupReplyItemData(
sealed class ReplyBody {
data class Text(val message: ConversationMessage) : ReplyBody()
data class Reaction(val emoji: CharSequence) : ReplyBody()
data class RemoteDelete(val messageRecord: MessageRecord) : ReplyBody()
}
sealed class Key {
data class Text(val messageId: Long) : Key()
data class Reaction(val reactionId: Long) : Key()
data class RemoteDelete(val messageId: Long) : Key()
}
}

View file

@ -29,6 +29,7 @@ class StoryGroupReplyRepository {
val threadId = SignalDatabase.mms.getThreadIdForMessage(parentStoryId)
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageObserver)
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, observer)

View file

@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.sms.MessageSender
/**
@ -40,24 +41,26 @@ object StoryGroupReplySender {
Completable.create {
MessageSender.send(
context,
OutgoingMediaMessage(
recipient,
body.toString(),
emptyList(),
System.currentTimeMillis(),
0,
0L,
false,
0,
StoryType.NONE,
ParentStoryId.GroupReply(message.id),
isReaction,
null,
emptyList(),
emptyList(),
mentions,
emptySet(),
emptySet()
OutgoingSecureMediaMessage(
OutgoingMediaMessage(
recipient,
body.toString(),
emptyList(),
System.currentTimeMillis(),
0,
0L,
false,
0,
StoryType.NONE,
ParentStoryId.GroupReply(message.id),
isReaction,
null,
emptyList(),
emptyList(),
mentions,
emptySet(),
emptySet()
)
),
message.threadId,
false,

View file

@ -15,6 +15,17 @@ import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask
object DeleteDialog {
/**
* Displays a deletion dialog for the given set of message records.
*
* @param context Android Context
* @param messageRecords The message records to delete
* @param title The dialog title
* @param message The dialog message, or null
* @param forceRemoteDelete Allow remote deletion, even if it would normally be disallowed
*
* @return a Single, who's value notes whether or not a thread deletion occurred.
*/
fun show(
context: Context,
messageRecords: Set<MessageRecord>,

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:background="@drawable/selectable_list_item_background"
android:clipToPadding="false"
android:paddingHorizontal="8dp"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/avatar"
android:layout_width="28dp"
android:layout_height="28dp"
app:fallbackImageSize="small"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/rounded_rectangle_secondary_18"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent">
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="7dp"
android:layout_marginEnd="12dp"
android:textAppearance="@style/TextAppearance.Signal.Subtitle.Bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Miles Morales" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="1dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="1dp"
android:text="@string/ThreadRecord_this_message_was_deleted"
android:textAppearance="@style/Signal.Text.Body"
android:textStyle="italic"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@id/viewed_at_below"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name"
app:layout_goneMarginBottom="7dp"
app:measureLastLine="true" />
<TextView
android:id="@+id/viewed_at"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="5dp"
android:textAppearance="@style/Signal.Text.Caption"
android:textColor="@color/transparent_white_60"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toEndOf="@id/body"
tools:text="15m"
tools:textColor="@color/signal_text_secondary"
tools:visibility="visible" />
<TextView
android:id="@+id/viewed_at_below"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="5dp"
android:textAppearance="@style/Signal.Text.Caption"
android:textColor="@color/transparent_white_60"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/bubble"
app:layout_constraintEnd_toEndOf="@id/bubble"
tools:text="15m"
tools:textColor="@color/signal_text_secondary" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>