diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt index b0762171ee49bf60f17fb5bf893735f39d0e210b..6e1d3fe8cec8162a32db1da771bf5bce3456b996 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt index 5997ccb554b45bc972b258c4326865d1accbbbe5..939fc68f7d47ed4e81a26a4117e22cabfe140be3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -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.") + } + } + }, + ) + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt index 6cfed46956490bfb40de9fa1585ee0d5b4dbd578..04609031043ef6284db1d39c55804d428120ddf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItem.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt index 5fe19b1326100ac207daca31b7f5279a4251cf98..5acb4b66d02a2b7f8aba8f7dd1175e13976de058 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyItemData.kt @@ -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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt index 9cafbbe4d0d6fbc4e9319f53e92e1097ed90774f..85c10249047efd9e8f7cef81c26579269cc953de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyRepository.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt index 604f2643a5ee6e85ff47fa50bf642f9e8c371a64..859e86fce87a1ef1f84cc4db8a4f90f064441e03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt index 423f9a48684e8dea8d7b10873841bfd0f8665ca3..85aa8fd425ac9bde878e906a7f880ceb90e27fc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt @@ -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>, diff --git a/app/src/main/res/layout/stories_group_remote_delete_item.xml b/app/src/main/res/layout/stories_group_remote_delete_item.xml new file mode 100644 index 0000000000000000000000000000000000000000..48f93fe66e46d3009eca9309d501827305f51d69 --- /dev/null +++ b/app/src/main/res/layout/stories_group_remote_delete_item.xml @@ -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> \ No newline at end of file