mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Add basic pinned message support.
This commit is contained in:
committed by
jeffrey-signal
parent
22701da765
commit
80598d42cc
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,7 +35,8 @@ class V2FooterPositionDelegate private constructor(
|
||||
binding.footerDate,
|
||||
binding.deliveryStatus,
|
||||
binding.footerExpiry,
|
||||
binding.footerSpace
|
||||
binding.footerSpace,
|
||||
binding.footerPinned
|
||||
),
|
||||
binding.bodyWrapper,
|
||||
binding.body,
|
||||
|
||||
Reference in New Issue
Block a user