Add basic pinned message support.

This commit is contained in:
Michelle Tang
2025-11-24 13:18:36 -05:00
committed by jeffrey-signal
parent 22701da765
commit 80598d42cc
70 changed files with 2162 additions and 89 deletions

View File

@@ -1921,7 +1921,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE;
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE || messageRecord.getPinnedUntil() > 0;
}
private boolean forceGroupHeader(@NonNull MessageRecord messageRecord) {

View File

@@ -723,6 +723,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL)));
}
if (menuState.shouldShowPinMessage()) {
items.add(new ActionItem(R.drawable.symbol_pin_24, getResources().getString(R.string.conversation_selection__menu_pin_message), () -> handleActionItemClicked(Action.PIN_MESSAGE)));
}
if (menuState.showShowUnpinMessage()) {
items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getString(R.string.conversation_selection__menu_unpin_message), () -> handleActionItemClicked(Action.UNPIN_MESSAGE)));
}
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
@@ -908,6 +916,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
PAYMENT_DETAILS,
VIEW_INFO,
DELETE,
END_POLL
END_POLL,
PIN_MESSAGE,
UNPIN_MESSAGE
}
}

View File

@@ -685,6 +685,17 @@ public final class ConversationUpdateItem extends FrameLayout
passthroughClickListener.onClick(v);
}
});
} else if (MessageRecordUtil.hasPinnedMessageUpdate(conversationMessage.getMessageRecord())) {
actionButton.setText(R.string.PinnedMessage__go_to_message);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
// TODO(michelle): Handle when a message gets deleted
if (batchSelected.isEmpty() && eventListener != null && MessageRecordUtil.hasPinnedMessageUpdate(conversationMessage.getMessageRecord())) {
eventListener.onViewPinnedMessage(conversationMessage.getMessageRecord().getMessageExtras().pinnedMessage.pinnedMessageId);
} else {
passthroughClickListener.onClick(v);
}
});
} else {
actionButton.setVisibility(GONE);
actionButton.setOnClickListener(null);

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import java.util.Set;
import java.util.stream.Collectors;
@@ -27,6 +28,8 @@ public final class MenuState {
private final boolean paymentDetails;
private final boolean edit;
private final boolean pollTerminate;
private final boolean pinMessage;
private final boolean unpinMessage;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -40,6 +43,8 @@ public final class MenuState {
paymentDetails = builder.paymentDetails;
edit = builder.edit;
pollTerminate = builder.pollTerminate;
pinMessage = builder.pinMessage;
unpinMessage = builder.unpinMessage;
}
public boolean shouldShowForwardAction() {
@@ -86,6 +91,14 @@ public final class MenuState {
return pollTerminate;
}
public boolean shouldShowPinMessage() {
return pinMessage;
}
public boolean showShowUnpinMessage() {
return unpinMessage;
}
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
@@ -105,6 +118,8 @@ public final class MenuState {
boolean hasPayment = false;
boolean hasPoll = false;
boolean hasPollTerminate = false;
boolean canPinMessage = false;
boolean canUnpinMessage = false;
for (MultiselectPart part : selectedParts) {
MessageRecord messageRecord = part.getMessageRecord();
@@ -154,6 +169,14 @@ public final class MenuState {
if (MessageRecordUtil.hasPoll(messageRecord) && !MessageRecordUtil.getPoll(messageRecord).getHasEnded() && messageRecord.isOutgoing()) {
hasPollTerminate = true;
}
if (RemoteConfig.sendPinnedMessages() && !messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes()) { // TODO(michelle): Also check against group permissions
canPinMessage = true;
}
if (RemoteConfig.sendPinnedMessages() && messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes()) { // TODO(michelle): Also check against group permissions
canUnpinMessage = true;
}
}
boolean shouldShowForwardAction = !actionMessage &&
@@ -178,7 +201,9 @@ public final class MenuState {
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false)
.shouldShowEdit(false)
.shouldShowPollTerminate(false);
.shouldShowPollTerminate(false)
.shouldShowPinMessage(false)
.shouldShowUnpinMessage(false);
} else {
MultiselectPart multiSelectRecord = selectedParts.iterator().next();
@@ -210,6 +235,8 @@ public final class MenuState {
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
.shouldShowPaymentDetails(hasPayment)
.shouldShowPollTerminate(hasPollTerminate)
.shouldShowPinMessage(canPinMessage)
.shouldShowUnpinMessage(canUnpinMessage)
.build();
}
@@ -255,6 +282,8 @@ public final class MenuState {
private boolean paymentDetails;
private boolean edit;
private boolean pollTerminate;
private boolean pinMessage;
private boolean unpinMessage;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -311,6 +340,16 @@ public final class MenuState {
return this;
}
@NonNull Builder shouldShowPinMessage(boolean pinMessage) {
this.pinMessage = pinMessage;
return this;
}
@NonNull Builder shouldShowUnpinMessage(boolean unpinMessage) {
this.unpinMessage = unpinMessage;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View File

@@ -0,0 +1,289 @@
package org.thoughtcrime.securesms.conversation
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.fragments.findListener
import java.util.Locale
/**
* Bottom sheet to show all pinned messages
*/
class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private lateinit var messageAdapter: ConversationAdapter
private val viewModel: PinnedMessagesViewModel by viewModels(
factoryProducer = {
val threadId = requireArguments().getLong(KEY_THREAD_ID, -1)
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
PinnedMessagesViewModel.Factory(AppDependencies.application, threadId, conversationRecipientId)
}
)
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.pinned_messages_bottom_sheet, container, false)
disposables.bindTo(viewLifecycleOwner)
return view
}
@SuppressLint("WrongThread")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
val conversationRecipient = Recipient.resolved(conversationRecipientId)
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
setCondensedMode(ConversationItemDisplayMode.Condensed(scheduleMessageMode = false))
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.pinned_list).apply {
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
adapter = messageAdapter
itemAnimator = null
doOnNextLayout {
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
}
}
val recyclerViewColorizer = RecyclerViewColorizer(list)
disposables += viewModel.getMessages().subscribe { messages ->
if (messages.isEmpty()) {
dismiss()
}
messageAdapter.submitList(messages) {
if (!list.canScrollVertically(1)) {
list.layoutManager?.scrollToPosition(0)
}
}
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
disposables += viewModel.getNameColorsMap().subscribe { map ->
colorizer.onNameColorsChanged(map)
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapterBridge.PAYLOAD_NAME_COLORS)
}
initializeGiphyMp4(view.findViewById(R.id.video_container)!!, list)
// TODO(michelle): Hide if not allowed to unpin / Check with design about a confirmation dialog here
val unpinAll = view.findViewById<TextView>(R.id.unpin_all)
unpinAll.setOnClickListener {
viewModel.unpinMessage()
dismissAllowingStateLoss()
}
}
private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private fun getCallback(): ConversationBottomSheetCallback {
return findListener<ConversationBottomSheetCallback>() ?: throw IllegalStateException("Parent must implement callback interface!")
}
private fun getAdapterListener(): ConversationAdapter.ItemClickListener {
return getCallback().getConversationAdapterListener()
}
// TODO(michelle): Allow for more interactions from the pinned messages sheet
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
override fun onItemClick(item: MultiselectPart) {
dismiss()
getCallback().jumpToMessage(item.getMessageRecord())
}
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
dismiss()
getCallback().jumpToMessage(item.getMessageRecord())
}
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
dismiss()
getCallback().jumpToMessage(messageRecord)
}
override fun onLinkPreviewClicked(linkPreview: LinkPreview) {
dismiss()
getAdapterListener().onLinkPreviewClicked(linkPreview)
}
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onQuotedIndicatorClicked(messageRecord)
}
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) {
dismiss()
getCallback().jumpToMessage(multiselectPart.conversationMessage.messageRecord)
}
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {
dismiss()
getAdapterListener().onGroupMemberClicked(recipientId, groupId)
}
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onMessageWithRecaptchaNeededClicked(messageRecord)
}
override fun onSingleVoiceNotePlay(uri: Uri, messageId: Long, position: Double) {
super.onSingleVoiceNotePlay(uri, messageId, position)
}
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) {
dismiss()
getAdapterListener().onGroupMigrationLearnMoreClicked(membershipChange)
}
override fun onChatSessionRefreshLearnMoreClicked() {
dismiss()
getAdapterListener().onChatSessionRefreshLearnMoreClicked()
}
override fun onBadDecryptLearnMoreClicked(author: RecipientId) {
dismiss()
getAdapterListener().onBadDecryptLearnMoreClicked(author)
}
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) {
dismiss()
getAdapterListener().onSafetyNumberLearnMoreClicked(recipient)
}
override fun onJoinGroupCallClicked() {
dismiss()
getAdapterListener().onJoinGroupCallClicked()
}
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) {
dismiss()
getAdapterListener().onInviteFriendsToGroupClicked(groupId)
}
override fun onEnableCallNotificationsClicked() {
dismiss()
getAdapterListener().onEnableCallNotificationsClicked()
}
override fun onCallToAction(action: String) {
dismiss()
getAdapterListener().onCallToAction(action)
}
override fun onDonateClicked() {
dismiss()
getAdapterListener().onDonateClicked()
}
override fun onRecipientNameClicked(target: RecipientId) {
dismiss()
getAdapterListener().onRecipientNameClicked(target)
}
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onViewGiftBadgeClicked(messageRecord)
}
override fun onActivatePaymentsClicked() {
dismiss()
getAdapterListener().onActivatePaymentsClicked()
}
override fun onSendPaymentClicked(recipientId: RecipientId) {
dismiss()
getAdapterListener().onSendPaymentClicked(recipientId)
}
override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) {
dismiss()
getAdapterListener().onEditedIndicatorClicked(conversationMessage)
}
override fun onShowSafetyTips(forGroup: Boolean) = Unit
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
override fun onItemDoubleClick(item: MultiselectPart) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
}
companion object {
private val TAG = Log.tag(PinnedMessagesBottomSheet::class.java)
private const val KEY_THREAD_ID = "thread_id"
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) {
val args = Bundle().apply {
putLong(KEY_THREAD_ID, threadId)
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
}
val fragment = PinnedMessagesBottomSheet().apply {
arguments = args
}
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.conversation
import android.app.Application
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper
import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
/**
* Repository when getting the pinned messages shown in the pinned message bottom sheet
*/
class PinnedMessagesRepository {
companion object {
private val TAG = Log.tag(PinnedMessagesRepository::class.java)
}
fun getPinnedMessage(application: Application, threadId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
emitter.onNext(getPinnedMessages(application, threadId))
}.subscribeOn(Schedulers.io())
}
fun getPinnedMessageRecords(threadId: Long): List<MessageRecord> {
return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = false)
}
private fun getPinnedMessages(application: Application, threadId: Long): List<ConversationMessage> {
var records: List<MessageRecord> = getPinnedMessageRecords(threadId)
val reactionHelper = ReactionHelper()
val attachmentHelper = AttachmentHelper()
val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(threadId))
reactionHelper.addAll(records)
attachmentHelper.addAll(records)
reactionHelper.fetchReactions()
attachmentHelper.fetchAttachments()
records = reactionHelper.buildUpdatedModels(records)
records = attachmentHelper.buildUpdatedModels(AppDependencies.application, records)
return records.map { ConversationMessageFactory.createWithUnresolvedData(application, it, threadRecipient) }
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.conversation
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.UnpinMessageJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model for the pinned messages bottom sheet
*/
class PinnedMessagesViewModel(
application: Application,
private val threadId: Long,
private val conversationRecipientId: RecipientId
) : AndroidViewModel(application) {
companion object {
private val TAG = Log.tag(PinnedMessagesViewModel::class.java)
}
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
private val repository = PinnedMessagesRepository()
fun getMessages(): Observable<List<ConversationMessage>> {
return repository
.getPinnedMessage(getApplication(), threadId)
.observeOn(AndroidSchedulers.mainThread())
}
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
return Observable.just(conversationRecipientId)
.map { conversationRecipientId ->
val conversationRecipient = Recipient.resolved(conversationRecipientId)
if (conversationRecipient.groupId.isPresent) {
groupAuthorNameColorHelper.getColorMap(conversationRecipient.groupId.get())
} else {
emptyMap()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun unpinMessage() {
viewModelScope.launch(Dispatchers.IO) {
repository.getPinnedMessageRecords(threadId).map {
val unpinJob = UnpinMessageJob.create(messageId = it.id)
if (unpinJob != null) {
AppDependencies.jobManager.add(unpinJob)
} else {
Log.w(TAG, "Unable to create unpin job for message ${it.id}, ignoring.")
}
}
}
}
class Factory(private val application: Application, private val threadId: Long, private val conversationRecipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(PinnedMessagesViewModel(application, threadId, conversationRecipientId)) as T
}
}
}

View File

@@ -21,11 +21,14 @@ import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.BannerManager
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.IdentityUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.views.Stub
@@ -50,6 +53,7 @@ class ConversationBannerView @JvmOverloads constructor(
private val bannerStub: Stub<ComposeView> by lazy { ViewUtil.findStubById(this, R.id.banner_stub) }
private val reviewBannerStub: Stub<ReviewBannerView> by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) }
private val voiceNotePlayerStub: Stub<View> by lazy { ViewUtil.findStubById(this, R.id.voice_note_player_stub) }
private val pinnedMessageStub: Stub<ComposeView> by lazy { ViewUtil.findStubById(this, R.id.pinned_message_stub) }
var listener: Listener? = null
@@ -129,6 +133,29 @@ class ConversationBannerView @JvmOverloads constructor(
hide(voiceNotePlayerStub)
}
fun showPinnedMessageStub(messages: List<ConversationMessage>) {
show(
stub = pinnedMessageStub
) {
this.apply {
setContent {
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) {
PinnedMessagesBanner(
messages = messages,
onUnpinMessage = { messageId -> listener?.onUnpinMessage(messageId) },
onGoToMessage = { messageId -> listener?.onGoToMessage(messageId) },
onViewAllMessages = { listener?.onViewAllMessages() }
)
}
}
}
}
}
fun hidePinnedMessageStub() {
hide(pinnedMessageStub)
}
private fun <V : View> show(stub: Stub<V>, bind: V.() -> Unit = {}) {
TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP))
stub.get().bind()
@@ -177,5 +204,8 @@ class ConversationBannerView @JvmOverloads constructor(
fun onRequestReviewIndividual(recipientId: RecipientId)
fun onReviewGroupMembers(groupId: GroupId.V2)
fun onDismissReview()
fun onUnpinMessage(messageId: Long)
fun onGoToMessage(messageId: Long)
fun onViewAllMessages()
}
}

View File

@@ -188,6 +188,7 @@ import org.thoughtcrime.securesms.conversation.MarkReadHelper
import org.thoughtcrime.securesms.conversation.MenuState
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.MessageStyler.getStyling
import org.thoughtcrime.securesms.conversation.PinnedMessagesBottomSheet
import org.thoughtcrime.securesms.conversation.ReenableScheduledMessagesDialogFragment
import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu
import org.thoughtcrime.securesms.conversation.ScheduleMessageDialogCallback
@@ -377,6 +378,7 @@ import java.time.ZoneId
import java.util.Locale
import java.util.Optional
import java.util.concurrent.ExecutionException
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -1179,6 +1181,16 @@ class ConversationFragment :
}
}
lifecycleScope.launch {
viewModel
.pinnedMessages
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.flowOn(Dispatchers.Main)
.collect {
presentPinnedMessage(it)
}
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val recipient = viewModel.recipientSnapshot
@@ -1322,6 +1334,14 @@ class ConversationFragment :
.addTo(disposables)
}
private fun presentPinnedMessage(pinnedMessages: List<ConversationMessage>) {
if (pinnedMessages.isNotEmpty()) {
binding.conversationBanner.showPinnedMessageStub(pinnedMessages)
} else {
binding.conversationBanner.hidePinnedMessageStub()
}
}
private fun presentTypingIndicator() {
typingIndicatorAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
@@ -1656,6 +1676,55 @@ class ConversationFragment :
}
}
private fun handlePinMessage(conversationMessage: ConversationMessage) {
if (viewModel.pinnedMessages.value.size >= RemoteConfig.pinLimit) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getString(R.string.ConversationFragment__replace_title))
.setMessage(resources.getString(R.string.ConversationFragment__replace_body))
.setPositiveButton(R.string.ConversationFragment__replace) { _, _ ->
showPinForDialog(conversationMessage)
}
.setNegativeButton(R.string.ConversationFragment__cancel) { dialog, _ -> dialog.dismiss() }
.show()
} else {
showPinForDialog(conversationMessage)
}
}
private fun showPinForDialog(conversationMessage: ConversationMessage) {
var selection = 1
val labels = resources.getStringArray(R.array.ConversationFragment__pinned_for_labels)
val values = resources.getIntArray(R.array.ConversationFragment__pinned_for_values)
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getString(R.string.ConversationFragment__keep_pinned))
.setSingleChoiceItems(labels, selection) { dialog, which ->
selection = which
}
.setPositiveButton(android.R.string.ok) { dialog, _ ->
if (conversationMessage.messageRecord.expiresIn > 0 && SignalStore.uiHints.shouldDisplayPinnedSheet()) {
PinDisappearingMessageBottomSheet.show(childFragmentManager)
SignalStore.uiHints.incrementSeenPinnedSheetCount()
}
disposables += viewModel
.pinMessage(
messageRecord = conversationMessage.messageRecord,
duration = if (values[selection] == -1) kotlin.time.Duration.INFINITE else values[selection].days,
threadRecipient = conversationMessage.threadRecipient
)
.subscribe()
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
private fun handleUnpinMessage(messageId: Long) {
viewModel.unpinMessage(messageId)
}
private fun handleVideoCall() {
val recipient = viewModel.recipientSnapshot ?: return
if (!recipient.isGroup) {
@@ -2712,6 +2781,15 @@ class ConversationFragment :
progressDialog = null
}
private fun viewPinnedMessage(messageId: Long) {
disposables += viewModel
.moveToMessage(messageId)
.subscribeBy(
onSuccess = { moveToPosition(it) },
onError = { Toast.makeText(requireContext(), R.string.PinnedMessage__not_found, Toast.LENGTH_LONG).show() }
)
}
private inner class SwipeAvailabilityProvider : ConversationItemSwipeCallback.SwipeAvailabilityProvider {
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
val recipient = viewModel.recipientSnapshot ?: return false
@@ -3243,6 +3321,10 @@ class ConversationFragment :
viewModel.toggleVote(poll, pollOption, isChecked)
}
override fun onViewPinnedMessage(messageId: Long) {
viewPinnedMessage(messageId)
}
override fun onJoinGroupCallClicked() {
val activity = activity ?: return
val recipient = viewModel.recipientSnapshot ?: return
@@ -3936,6 +4018,8 @@ class ConversationFragment :
ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage)
ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id)
ConversationReactionOverlay.Action.PIN_MESSAGE -> handlePinMessage(conversationMessage)
ConversationReactionOverlay.Action.UNPIN_MESSAGE -> handleUnpinMessage(conversationMessage.messageRecord.id)
}
}
}
@@ -4136,6 +4220,22 @@ class ConversationFragment :
override fun onDismissReview() {
viewModel.onDismissReview()
}
override fun onUnpinMessage(messageId: Long) {
handleUnpinMessage(messageId)
}
override fun onGoToMessage(messageId: Long) {
viewPinnedMessage(messageId)
}
override fun onViewAllMessages() {
PinnedMessagesBottomSheet.show(
childFragmentManager,
args.threadId,
viewModel.recipientSnapshot?.id!!
)
}
}
//endregion

View File

@@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.AppDependencies.databaseObserver
@@ -300,6 +301,38 @@ class ConversationRepository(
) { System.currentTimeMillis() - sentTime > POLL_TERMINATE_TIMEOUT.inWholeMilliseconds }
}
fun getPinnedMessages(threadId: Long): List<MmsMessageRecord> {
return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = true)
}
fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable {
return Completable.create { emitter ->
val message = OutgoingMessage.pinMessage(
threadRecipient = threadRecipient,
sentTimeMillis = System.currentTimeMillis(),
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
messageExtras = MessageExtras(
pinnedMessage = PinnedMessage(
pinnedMessageId = messageRecord.id,
targetAuthorAci = messageRecord.fromRecipient.requireAci().toByteString(),
targetTimestamp = messageRecord.dateSent,
pinDurationInSeconds = if (duration.isInfinite()) MessageTable.PIN_FOREVER else duration.inWholeSeconds
)
)
)
Log.i(TAG, "Sending pin create to ${message.threadRecipient.id}, thread: ${messageRecord.threadId}")
MessageSender.send(
AppDependencies.application,
message,
messageRecord.threadId,
MessageSender.SendType.SIGNAL,
null
) { emitter.onComplete() }
}.subscribeOn(Schedulers.io())
}
private fun applyUniversalExpireTimerIfNecessary(context: Context, recipient: Recipient, outgoingMessage: OutgoingMessage, threadId: Long): OutgoingMessage {
if (!outgoingMessage.isExpirationUpdate && outgoingMessage.expiresIn == 0L) {
val expireTimerVersion = RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId)

View File

@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.PollVoteJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.UnpinMessageJob
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
@@ -207,6 +208,9 @@ class ConversationViewModel(
private val internalBackPressedState = MutableStateFlow(BackPressedState())
val backPressedState: StateFlow<BackPressedState> = internalBackPressedState
private val internalPinnedMessages = MutableStateFlow<List<ConversationMessage>>(emptyList())
val pinnedMessages: StateFlow<List<ConversationMessage>> = internalPinnedMessages
init {
disposables += recipient
.subscribeBy {
@@ -237,6 +241,8 @@ class ConversationViewModel(
_conversationThreadState.onNext(it)
})
getPinnedMessages()
disposables += conversationThreadState.flatMapObservable { threadState ->
Observable.create<Unit> { emitter ->
val controller = threadState.items.controller
@@ -248,6 +254,7 @@ class ConversationViewModel(
}
val conversationObserver = DatabaseObserver.Observer {
controller.onDataInvalidated()
getPinnedMessages()
}
AppDependencies.databaseObserver.registerMessageUpdateObserver(messageUpdateObserver)
@@ -339,6 +346,32 @@ class ConversationViewModel(
}
}
private fun getPinnedMessages() {
viewModelScope.launch(Dispatchers.IO) {
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
internalPinnedMessages.value = repository.getPinnedMessages(threadId).map {
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(AppDependencies.application, it, threadRecipient!!)
}
}
}
fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable {
return repository
.pinMessage(messageRecord, duration, threadRecipient)
.observeOn(AndroidSchedulers.mainThread())
}
fun unpinMessage(messageId: Long) {
viewModelScope.launch(Dispatchers.IO) {
val unpinJob = UnpinMessageJob.create(messageId = messageId)
if (unpinJob != null) {
AppDependencies.jobManager.add(unpinJob)
} else {
Log.w(TAG, "Unable to create unpin job, ignoring.")
}
}
}
fun updateThreadHeader() {
pagingController.onDataItemChanged(ConversationElementKey.threadHeader)
}

View File

@@ -0,0 +1,97 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* Bottom sheet informing users about pinning disappearing messages
*/
class PinDisappearingMessageBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
PinDisappearingMessageBottomSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
PinDisappearingSheet(
onDismiss = { dismissAllowingStateLoss() }
)
}
}
@Composable
fun PinDisappearingSheet(
onDismiss: () -> Unit = {}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
BottomSheets.Handle()
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_timer_80),
contentDescription = stringResource(R.string.PinnedMessage__disappearing_message_content_description),
modifier = Modifier.padding(vertical = 24.dp),
tint = Color.Unspecified
)
Text(
text = stringResource(R.string.PinnedMessage__disappearing_message_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.PinnedMessage__disappearing_message_body),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 4.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Buttons.LargeTonal(
onClick = onDismiss,
modifier = Modifier.padding(top = 40.dp, bottom = 56.dp)
) {
Text(stringResource(id = R.string.PinnedMessage__got_it))
}
}
}
@DayNightPreviews
@Composable
private fun PinnedDialogPreview() {
Previews.Preview {
PinDisappearingSheet()
}
}

View File

@@ -0,0 +1,221 @@
package org.thoughtcrime.securesms.conversation.v2
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.TextUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.doOnPreDraw
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.getSpannedString
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.DecryptableUri
import org.thoughtcrime.securesms.mms.DocumentSlide
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.StickerSlide
import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.util.hasSharedContact
import org.thoughtcrime.securesms.util.isPoll
import org.thoughtcrime.securesms.util.isViewOnceMessage
import org.whispersystems.signalservice.api.payments.FormatterOptions
import kotlin.jvm.optionals.getOrDefault
/**
* Displays pinned messages banner on conversation fragment
*/
@Composable
fun PinnedMessagesBanner(
messages: List<ConversationMessage> = emptyList(),
onUnpinMessage: (Long) -> Unit = {},
onGoToMessage: (Long) -> Unit = {},
onViewAllMessages: () -> Unit = {}
) {
val menuController = remember { DropdownMenus.MenuController() }
var index by remember(messages) { mutableIntStateOf(messages.size - 1) }
val conversationMessage = messages[index % messages.size]
val message = conversationMessage.messageRecord as MmsMessageRecord
val (glyph, body, showThumbnail) = getMessageMetadata(conversationMessage)
Column {
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(SignalTheme.colors.colorSurface2)
.clickable {
index = (index + 1) % messages.size
onGoToMessage(message.id)
}
.padding(8.dp)
.height(IntrinsicSize.Min)
) {
if (messages.size > 1) {
Heading(index, messages.size)
}
if (showThumbnail && message.slideDeck.firstSlide?.uri != null) {
GlideImage(
model = DecryptableUri(message.slideDeck.firstSlide!!.uri!!),
modifier = Modifier
.padding(start = 8.dp)
.size(32.dp)
.clip(RoundedCornerShape(12.dp))
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = if (message.fromRecipient.isSelf) {
stringResource(R.string.Recipient_you)
} else {
message.fromRecipient.getDisplayName(LocalContext.current)
},
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
val displayBody = if (glyph != null) {
SpannableStringBuilder()
.append(getSpannedString(LocalContext.current, SignalSymbols.Weight.REGULAR, glyph, -1))
.append(" ")
.append(body)
} else {
body
}
AndroidView(
factory = ::EmojiTextView
) { view ->
view.enableRenderSpoilers()
view.text = displayBody
view.ellipsize = TextUtils.TruncateAt.END
view.maxLines = 1
view.doOnPreDraw {
(it as EmojiTextView).ellipsizeEmojiTextForMaxLines()
}
}
}
Box(modifier = Modifier.padding(horizontal = 8.dp)) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_pin_24),
contentDescription = stringResource(R.string.PinnedMessage__pinned),
modifier = Modifier
.clickable { menuController.show() }
.padding(vertical = 8.dp),
tint = MaterialTheme.colorScheme.onSurface
)
DropdownMenus.Menu(controller = menuController, offsetX = 2.dp, offsetY = 16.dp) { menuController ->
Column {
DropdownMenus.ItemWithIcon(menuController, R.drawable.symbol_pin_slash_24, R.string.PinnedMessage__unpin_message) { onUnpinMessage(message.id) }
DropdownMenus.ItemWithIcon(menuController, R.drawable.symbol_chat_24, R.string.PinnedMessage__go_to_message) { onGoToMessage(message.id) }
DropdownMenus.ItemWithIcon(menuController, R.drawable.symbol_list_bullet_24, R.string.PinnedMessage__view_all_messages) { onViewAllMessages() }
}
}
}
}
}
}
/**
* Heading to show how many pinned messages there are and which one (of three) is being displayed
*/
@Composable
fun Heading(selectedIndex: Int, size: Int) {
Column(
modifier = Modifier.fillMaxHeight()
) {
for (i in 0 until size) {
Box(
modifier = Modifier
.padding(vertical = 2.dp)
.width(2.dp)
.weight(1f)
.background(
color = if (i == selectedIndex) {
MaterialTheme.colorScheme.onSurface
} else {
SignalTheme.colors.colorTransparentInverse2
},
shape = RoundedCornerShape(16.dp)
)
)
}
}
}
/**
* Given the type of message, returns the associated glyph, body, and whether or not a thumbnail should be rendered with it
*/
@Composable
fun getMessageMetadata(conversationMessage: ConversationMessage): Triple<SignalSymbols.Glyph?, SpannableString, Boolean> {
val context = LocalContext.current
val message = conversationMessage.messageRecord as MmsMessageRecord
val slide = message.slideDeck.firstSlide
return if (slide is StickerSlide) {
Triple(SignalSymbols.Glyph.STICKER, SpannableString(stringResource(R.string.PinnedMessage__sticker)), false)
} else if (slide is AudioSlide) {
Triple(SignalSymbols.Glyph.AUDIO, SpannableString(stringResource(R.string.PinnedMessage__voice)), false)
} else if (slide is DocumentSlide) {
Triple(SignalSymbols.Glyph.FILE, SpannableString(slide.fileName.getOrDefault(stringResource(R.string.DocumentView_unnamed_file))), false)
} else if (message.isViewOnceMessage()) {
Triple(SignalSymbols.Glyph.VIEW_ONCE, SpannableString(stringResource(R.string.PinnedMessage__view_once)), false)
} else if (message.isPoll()) {
Triple(SignalSymbols.Glyph.POLL, SpannableString(stringResource(R.string.Poll__poll_question, message.body)), false)
} else if (message.hasSharedContact()) {
Triple(SignalSymbols.Glyph.PERSON_CIRCLE, SpannableString(message.sharedContacts.first().name.givenName), false)
} else if (message.isPaymentNotification && message.payment != null) {
Triple(SignalSymbols.Glyph.CREDIT_CARD, SpannableString(message.payment!!.amount.toString(FormatterOptions.defaults())), false)
} else if (slide?.isVideoGif == true) {
Triple(SignalSymbols.Glyph.GIF_RECTANGLE, SpannableString(stringResource(R.string.PinnedMessage__gif)), false)
} else if (slide is ImageSlide && message.body.isEmpty()) {
Triple(null, SpannableString(stringResource(R.string.PinnedMessage__photo)), true)
} else if (slide is VideoSlide && message.body.isEmpty()) {
Triple(null, SpannableString(stringResource(R.string.PinnedMessage__video)), true)
} else {
Triple(null, conversationMessage.getDisplayBody(context), true)
}
}

View File

@@ -43,7 +43,8 @@ fun V2ConversationItemMediaIncomingBinding.bridge(): V2ConversationItemMediaBind
footerBackground = conversationItemFooterBackground,
alert = null,
footerSpace = null,
isIncoming = true
isIncoming = true,
footerPinned = conversationItemFooterPinned
)
return V2ConversationItemMediaBindingBridge(
@@ -73,7 +74,8 @@ fun V2ConversationItemMediaOutgoingBinding.bridge(): V2ConversationItemMediaBind
footerBackground = conversationItemFooterBackground,
alert = conversationItemAlert,
footerSpace = footerEndPad,
isIncoming = false
isIncoming = false,
footerPinned = conversationItemFooterPinned
)
return V2ConversationItemMediaBindingBridge(

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Space
import android.widget.TextView
import com.google.android.material.imageview.ShapeableImageView
@@ -41,7 +42,8 @@ data class V2ConversationItemTextOnlyBindingBridge(
val footerBackground: View,
val footerSpace: Space?,
val alert: AlertView?,
val isIncoming: Boolean
val isIncoming: Boolean,
val footerPinned: ImageView
)
/**
@@ -63,7 +65,8 @@ fun V2ConversationItemTextOnlyIncomingBinding.bridge(): V2ConversationItemTextOn
footerBackground = conversationItemFooterBackground,
alert = null,
footerSpace = footerEndPad,
isIncoming = true
isIncoming = true,
footerPinned = conversationItemFooterPinned
)
}
@@ -86,6 +89,7 @@ fun V2ConversationItemTextOnlyOutgoingBinding.bridge(): V2ConversationItemTextOn
footerBackground = conversationItemFooterBackground,
alert = conversationItemAlert,
footerSpace = footerEndPad,
isIncoming = false
isIncoming = false,
footerPinned = conversationItemFooterPinned
)
}

View File

@@ -95,7 +95,8 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
binding.footerDate,
binding.footerExpiry,
binding.deliveryStatus,
binding.footerBackground
binding.footerBackground,
binding.footerPinned
)
override val reactionsView: View = binding.reactions
@@ -257,6 +258,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
presentDate()
presentDeliveryStatus()
presentFooterBackground()
presentFooterPinned()
presentFooterExpiry()
presentFooterEndPadding()
presentAlert()
@@ -531,6 +533,12 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
}
}
private fun presentFooterPinned() {
val pinned = binding.footerPinned
pinned.setColorFilter(themeDelegate.getFooterForegroundColor(conversationMessage), PorterDuff.Mode.SRC_IN)
pinned.visible = conversationMessage.messageRecord.pinnedUntil > 0
}
private fun presentFooterEndPadding() {
binding.footerSpace?.visibility = if (isForcedFooter() || shape.isEndingShape) {
View.INVISIBLE
@@ -802,7 +810,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
}
private fun isForcedFooter(): Boolean {
return conversationMessage.messageRecord.isEditMessage || conversationMessage.messageRecord.expiresIn > 0L
return conversationMessage.messageRecord.isEditMessage || conversationMessage.messageRecord.expiresIn > 0L || conversationMessage.messageRecord.pinnedUntil > 0
}
private inner class ReactionMeasureListener : V2ConversationItemLayout.OnMeasureListener {

View File

@@ -35,7 +35,8 @@ class V2FooterPositionDelegate private constructor(
binding.footerDate,
binding.deliveryStatus,
binding.footerExpiry,
binding.footerSpace
binding.footerSpace,
binding.footerPinned
),
binding.bodyWrapper,
binding.body,