Improve the pinned messages bottom sheet.

This commit is contained in:
Michelle Tang
2025-11-25 12:20:50 -05:00
committed by jeffrey-signal
parent c9a0fb30b0
commit d2c3861ac7
7 changed files with 210 additions and 26 deletions

View File

@@ -9,4 +9,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
interface ConversationBottomSheetCallback { interface ConversationBottomSheetCallback {
fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener
fun jumpToMessage(messageRecord: MessageRecord) fun jumpToMessage(messageRecord: MessageRecord)
fun unpin(conversationMessage: ConversationMessage)
fun copy(conversationMessage: ConversationMessage)
fun delete(conversationMessage: ConversationMessage)
fun save(conversationMessage: ConversationMessage)
} }

View File

@@ -0,0 +1,114 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.view.View
import android.view.ViewGroup
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.isPoll
import org.thoughtcrime.securesms.util.isViewOnceMessage
/**
* A context menu shown when long pressing on a pinned messages
*/
object PinnedContextMenu {
fun show(
context: Context,
anchorView: View,
rootView: ViewGroup = anchorView.rootView as ViewGroup,
message: MmsMessageRecord,
canUnpin: Boolean,
onUnpin: () -> Unit = {},
onCopy: () -> Unit = {},
onDelete: () -> Unit = {},
onSave: () -> Unit = {}
) {
show(
context = context,
anchorView = anchorView,
rootView = rootView,
message = message,
canUnpin = canUnpin,
callbacks = object : Callbacks {
override fun onUnpin() = onUnpin()
override fun onCopy() = onCopy()
override fun onDelete() = onDelete()
override fun onSave() = onSave()
}
)
}
private fun show(
context: Context,
anchorView: View,
rootView: ViewGroup,
message: MmsMessageRecord,
canUnpin: Boolean,
callbacks: Callbacks
) {
val actions = mutableListOf<ActionItem>().apply {
if (canUnpin) {
add(
ActionItem(R.drawable.symbol_pin_slash_24, context.getString(R.string.PinnedMessage__unpin)) {
callbacks.onUnpin()
}
)
}
if (message.body.isNotEmpty() &&
!message.isRemoteDelete &&
!message.isPaymentNotification &&
!message.isPoll() &&
!message.hasGiftBadge()
) {
add(
ActionItem(R.drawable.symbol_copy_android_24, context.getString(R.string.conversation_selection__menu_copy)) {
callbacks.onCopy()
}
)
}
if (!message.isRemoteDelete) {
add(
ActionItem(R.drawable.symbol_trash_24, context.getString(R.string.conversation_selection__menu_delete)) {
callbacks.onDelete()
}
)
}
if (
!message.isViewOnceMessage() &&
!message.isMediaPending &&
!message.hasGiftBadge() &&
message.containsMediaSlide() &&
message.slideDeck.getStickerSlide() == null
) {
add(
ActionItem(R.drawable.symbol_save_android_24, context.getString(R.string.conversation_selection__menu_save)) {
callbacks.onSave()
}
)
}
}
val horizontalPosition = if (message.isOutgoing) SignalContextMenu.HorizontalPosition.END else SignalContextMenu.HorizontalPosition.START
SignalContextMenu.Builder(anchorView, rootView)
.preferredHorizontalPosition(horizontalPosition)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.offsetX(DimensionUnit.DP.toPixels(16f).toInt())
.offsetY(DimensionUnit.DP.toPixels(4f).toInt())
.show(actions)
}
private interface Callbacks {
fun onUnpin()
fun onCopy()
fun onDelete()
fun onSave()
}
}

View File

@@ -12,6 +12,7 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
@@ -81,7 +82,6 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.pinned_list).apply { val list: RecyclerView = view.findViewById<RecyclerView>(R.id.pinned_list).apply {
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true) layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
adapter = messageAdapter adapter = messageAdapter
itemAnimator = null
doOnNextLayout { doOnNextLayout {
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view // Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
@@ -96,11 +96,14 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
dismiss() dismiss()
} }
val currentMessages = messageAdapter.currentList
if (currentMessages != messages) {
messageAdapter.submitList(messages) { messageAdapter.submitList(messages) {
if (!list.canScrollVertically(1)) { if (!list.canScrollVertically(1)) {
list.layoutManager?.scrollToPosition(0) list.layoutManager?.scrollToPosition(0)
} }
} }
}
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors) recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
} }
@@ -111,12 +114,18 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
initializeGiphyMp4(view.findViewById(R.id.video_container)!!, list) initializeGiphyMp4(view.findViewById(R.id.video_container)!!, list)
// TODO(michelle): Check with design about a confirmation dialog here
val unpinAll = view.findViewById<TextView>(R.id.unpin_all) val unpinAll = view.findViewById<TextView>(R.id.unpin_all)
unpinAll.setOnClickListener { unpinAll.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinnedMessage__unpin_title)
.setMessage(getString(R.string.PinnedMessage__unpin_body))
.setPositiveButton(R.string.PinnedMessage__unpin) { dialog, which ->
viewModel.unpinMessage() viewModel.unpinMessage()
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
.setNegativeButton(android.R.string.cancel) { dialog, which -> dialog.dismiss() }
.show()
}
unpinAll.visible = requireArguments().getBoolean(KEY_CAN_UNPIN) unpinAll.visible = requireArguments().getBoolean(KEY_CAN_UNPIN)
} }
@@ -144,7 +153,6 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
return getCallback().getConversationAdapterListener() return getCallback().getConversationAdapterListener()
} }
// TODO(michelle): Allow for more interactions from the pinned messages sheet
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() { private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
override fun onItemClick(item: MultiselectPart) { override fun onItemClick(item: MultiselectPart) {
dismiss() dismiss()
@@ -152,8 +160,27 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
} }
override fun onItemLongClick(itemView: View, item: MultiselectPart) { override fun onItemLongClick(itemView: View, item: MultiselectPart) {
PinnedContextMenu.show(
context = itemView.context,
anchorView = itemView,
rootView = itemView.rootView as ViewGroup,
message = item.conversationMessage.messageRecord as MmsMessageRecord,
canUnpin = requireArguments().getBoolean(KEY_CAN_UNPIN),
onUnpin = {
dismiss() dismiss()
getCallback().jumpToMessage(item.getMessageRecord()) getCallback().unpin(item.conversationMessage)
},
onCopy = {
getCallback().copy(item.conversationMessage)
},
onDelete = {
dismiss()
getCallback().delete(item.conversationMessage)
},
onSave = {
getCallback().save(item.conversationMessage)
}
)
} }
override fun onQuoteClicked(messageRecord: MmsMessageRecord) { override fun onQuoteClicked(messageRecord: MmsMessageRecord) {

View File

@@ -7,6 +7,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper
import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -22,6 +23,11 @@ class PinnedMessagesRepository {
fun getPinnedMessage(application: Application, threadId: Long): Observable<List<ConversationMessage>> { fun getPinnedMessage(application: Application, threadId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter -> return Observable.create { emitter ->
val databaseObserver: DatabaseObserver = AppDependencies.databaseObserver
val observer = DatabaseObserver.Observer { emitter.onNext(getPinnedMessages(application, threadId)) }
databaseObserver.registerConversationObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getPinnedMessages(application, threadId)) emitter.onNext(getPinnedMessages(application, threadId))
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
} }

View File

@@ -820,6 +820,22 @@ class ConversationFragment :
.addTo(disposables) .addTo(disposables)
} }
override fun unpin(conversationMessage: ConversationMessage) {
handleUnpinMessage(conversationMessage.messageRecord.id)
}
override fun copy(conversationMessage: ConversationMessage) {
handleCopyMessage(conversationMessage.multiselectCollection.toSet())
}
override fun delete(conversationMessage: ConversationMessage) {
handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
}
override fun save(conversationMessage: ConversationMessage) {
handleSaveAttachment(conversationMessage.messageRecord as MmsMessageRecord)
}
override fun onReactWithAnyEmojiDialogDismissed() { override fun onReactWithAnyEmojiDialogDismissed() {
reactionDelegate.hide() reactionDelegate.hide()
} }

View File

@@ -19,6 +19,17 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/> app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/pinned_message_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/anchor"
android:layout_marginVertical="16dp"
android:text="@string/PinnedMessage__pinned_messages"
android:textAppearance="@style/Signal.Text.TitleMedium" />
<FrameLayout <FrameLayout
android:id="@+id/video_container" android:id="@+id/video_container"
android:layout_width="0dp" android:layout_width="0dp"
@@ -34,23 +45,21 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingBottom="40dp" android:paddingBottom="40dp"
android:clipToPadding="false" android:clipToPadding="false"
app:layout_constraintTop_toBottomOf="@id/anchor" app:layout_constraintTop_toBottomOf="@id/pinned_message_header"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/unpin_all" />
<TextView <com.google.android.material.button.MaterialButton
android:id="@+id/unpin_all" android:id="@+id/unpin_all"
style="@style/Signal.Widget.Button.Large.Tonal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:paddingVertical="8dp"
android:background="@color/signal_colorSurface"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
android:text="@string/PinnedMessage__unpin_all_messages" android:layout_marginVertical="8dp"
android:textAppearance="@style/Signal.Text.LabelLarge" android:layout_marginHorizontal="32dp"
android:textColor="@color/conversation_ultramarine" android:text="@string/PinnedMessage__unpin_all_messages" />
android:textAlignment="center"
/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -9062,6 +9062,14 @@
<string name="PinnedMessage__got_it">Got it</string> <string name="PinnedMessage__got_it">Got it</string>
<!-- Content description of disappearing timer icon --> <!-- Content description of disappearing timer icon -->
<string name="PinnedMessage__disappearing_message_content_description">Disappearing message</string> <string name="PinnedMessage__disappearing_message_content_description">Disappearing message</string>
<!-- Title of bottom sheet -->
<string name="PinnedMessage__pinned_messages">Pinned messages</string>
<!-- Context menu option to unpin message -->
<string name="PinnedMessage__unpin">Unpin</string>
<!-- Title of dialog to unpin all messages -->
<string name="PinnedMessage__unpin_title">Unpin all messages?</string>
<!-- Body of dialog to unpin all messages -->
<string name="PinnedMessage__unpin_body">Messages will be unpinned for all members.</string>
<!-- EOF --> <!-- EOF -->
</resources> </resources>