diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt
index f677b42e06..a7b36cb1d6 100644
--- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt
+++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt
@@ -356,5 +356,7 @@ class V2ConversationItemShapeTest {
override fun onViewPollClicked(messageId: Long) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit
+
+ override fun onViewPinnedMessage(messageId: Long) = Unit
}
}
diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt
index 2f7724dd71..cdef9e883e 100644
--- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt
+++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt
@@ -121,6 +121,7 @@ class ConversationElementGenerator {
null,
0,
false,
+ 0,
null
)
diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt
index 2ca0d4e4d8..b590f5fa25 100644
--- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt
+++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt
@@ -350,5 +350,9 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
+
+ override fun onViewPinnedMessage(messageId: Long) {
+ Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
+ }
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 54fa1d187a..50baf0c91d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1268,6 +1268,10 @@
android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm"
android:exported="false"/>
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
index 0ebf960358..4ed42de69e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
@@ -148,5 +148,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onViewResultsClicked(long pollId);
void onViewPollClicked(long messageId);
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
+ void onViewPinnedMessage(long messageId);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
index 046b2842b8..991f371de9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
@@ -55,6 +55,7 @@ public class ConversationItemFooter extends ConstraintLayout {
private ExpirationTimerView timerView;
private ImageView insecureIndicatorView;
private DeliveryStatusView deliveryStatusView;
+ private ImageView pinnedView;
private boolean onlyShowSendingStatus;
private TextView audioDuration;
private LottieAnimationView revealDot;
@@ -98,6 +99,7 @@ public class ConversationItemFooter extends ConstraintLayout {
timerView = findViewById(R.id.footer_expiration_timer);
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
deliveryStatusView = findViewById(R.id.footer_delivery_status);
+ pinnedView = findViewById(R.id.footer_pinned);
audioDuration = findViewById(R.id.footer_audio_duration);
revealDot = findViewById(R.id.footer_revealed_dot);
playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle);
@@ -143,6 +145,7 @@ public class ConversationItemFooter extends ConstraintLayout {
presentInsecureIndicator(messageRecord);
presentDeliveryStatus(messageRecord);
presentAudioDuration(messageRecord);
+ presentPinnedIcon(messageRecord);
}
public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
@@ -174,6 +177,7 @@ public class ConversationItemFooter extends ConstraintLayout {
timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
insecureIndicatorView.setColorFilter(color);
deliveryStatusView.setTint(color);
+ pinnedView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
public void setRevealDotColor(int color) {
@@ -428,6 +432,14 @@ public class ConversationItemFooter extends ConstraintLayout {
}
}
+ private void presentPinnedIcon(@NonNull MessageRecord messageRecord) {
+ if (messageRecord.getPinnedUntil() > 0) {
+ pinnedView.setVisibility(View.VISIBLE);
+ } else {
+ pinnedView.setVisibility(View.GONE);
+ }
+ }
+
private void showAudioDurationViews() {
audioDuration.setVisibility(View.VISIBLE);
revealDot.setVisibility(View.VISIBLE);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java
index b6df3032d5..457d95d6e5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java
@@ -472,7 +472,7 @@ public class EmojiTextView extends AppCompatTextView {
}
}
- private void ellipsizeEmojiTextForMaxLines() {
+ public void ellipsizeEmojiTextForMaxLines() {
Runnable ellipsize = () -> {
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
if (maxLines <= 0 && maxLength < 0) {
@@ -611,6 +611,13 @@ public class EmojiTextView extends AppCompatTextView {
}
}
+ public void enableRenderSpoilers() {
+ if (spoilerRendererDelegate == null) {
+ renderSpoilers = true;
+ spoilerRendererDelegate = new SpoilerRendererDelegate(this);
+ }
+ }
+
/**
* Due to some peculiarities in how TextView deals with touch events, it's really easy to accidentally trigger
* a click (say, when you try to scroll but you're at the bottom of a view.) Because of this, we handle these
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java
index 7e1b4e43fc..ff41271ae4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java
@@ -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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java
index 4fff0ec4bc..b9ee71bf9a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java
@@ -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
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java
index 52b51e927a..83d1275912 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java
@@ -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);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java
index d34d2970f7..ef8954c02d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java
@@ -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 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);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt
new file mode 100644
index 0000000000..598aad72e2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt
@@ -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(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(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() ?: 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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt
new file mode 100644
index 0000000000..75e4f5307a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt
@@ -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> {
+ return Observable.create { emitter ->
+ emitter.onNext(getPinnedMessages(application, threadId))
+ }.subscribeOn(Schedulers.io())
+ }
+
+ fun getPinnedMessageRecords(threadId: Long): List {
+ return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = false)
+ }
+
+ private fun getPinnedMessages(application: Application, threadId: Long): List {
+ var records: List = 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) }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt
new file mode 100644
index 0000000000..1805cc3bd1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt
@@ -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> {
+ return repository
+ .getPinnedMessage(getApplication(), threadId)
+ .observeOn(AndroidSchedulers.mainThread())
+ }
+
+ fun getNameColorsMap(): Observable