mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 18:30:20 +01:00
Add basic pinned message support.
This commit is contained in:
committed by
jeffrey-signal
parent
22701da765
commit
80598d42cc
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -111,6 +111,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
|
||||
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.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
@@ -140,6 +141,7 @@ import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.isStory
|
||||
@@ -155,6 +157,7 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper), MessageTypes, RecipientIdDatabaseReference, ThreadIdDatabaseReference {
|
||||
|
||||
@@ -217,6 +220,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val MESSAGE_EXTRAS = "message_extras"
|
||||
const val VOTES_UNREAD = "votes_unread"
|
||||
const val VOTES_LAST_SEEN = "votes_last_seen"
|
||||
const val PINNED_UNTIL = "pinned_until"
|
||||
const val PINNING_MESSAGE_ID = "pinning_message_id"
|
||||
const val PINNED_AT = "pinned_at"
|
||||
|
||||
const val QUOTE_NOT_PRESENT_ID = 0L
|
||||
const val QUOTE_TARGET_MISSING_ID = -1L
|
||||
@@ -224,6 +230,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val ADDRESSABLE_MESSAGE_LIMIT = 5
|
||||
const val PARENT_STORY_MISSING_ID = -1L
|
||||
|
||||
const val PIN_FOREVER = Long.MAX_VALUE
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -281,7 +289,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$MESSAGE_EXTRAS BLOB DEFAULT NULL,
|
||||
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL,
|
||||
$VOTES_UNREAD INTEGER DEFAULT 0,
|
||||
$VOTES_LAST_SEEN INTEGER DEFAULT 0
|
||||
$VOTES_LAST_SEEN INTEGER DEFAULT 0,
|
||||
$PINNED_UNTIL INTEGER DEFAULT 0,
|
||||
$PINNING_MESSAGE_ID INTEGER DEFAULT 0,
|
||||
$PINNED_AT INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -312,7 +323,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL",
|
||||
// This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0",
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)"
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)",
|
||||
"CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)",
|
||||
"CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)"
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION_BASE = arrayOf(
|
||||
@@ -367,7 +380,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
REVISION_NUMBER,
|
||||
MESSAGE_EXTRAS,
|
||||
VOTES_UNREAD,
|
||||
VOTES_LAST_SEEN
|
||||
VOTES_LAST_SEEN,
|
||||
PINNED_UNTIL
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
|
||||
@@ -2027,7 +2041,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0): Cursor {
|
||||
/**
|
||||
* Note: [reverse] and [orderBy] are mutually exclusive. If you want the order to be reversed, explicitly use 'ASC' or 'DESC'
|
||||
*/
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0, orderBy: String = ""): Cursor {
|
||||
val database = databaseHelper.signalReadableDatabase
|
||||
var rawQueryString = """
|
||||
SELECT
|
||||
@@ -2040,7 +2057,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$TABLE_NAME.$ID
|
||||
""".toSingleLine()
|
||||
|
||||
if (reverse) {
|
||||
if (orderBy.isNotEmpty()) {
|
||||
rawQueryString += " ORDER BY $orderBy"
|
||||
} else if (reverse) {
|
||||
rawQueryString += " ORDER BY $TABLE_NAME.$ID DESC"
|
||||
}
|
||||
|
||||
@@ -2068,6 +2087,27 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun getPinnedMessages(threadId: Long, orderByPinned: Boolean): List<MmsMessageRecord> {
|
||||
val cursor = rawQueryWithAttachments(
|
||||
where = "$THREAD_ID = ? AND $PINNED_UNTIL > 0",
|
||||
arguments = buildArgs(threadId),
|
||||
reverse = true,
|
||||
orderBy = if (orderByPinned) "$PINNED_AT ASC" else ""
|
||||
)
|
||||
|
||||
return mmsReaderFor(cursor).use { reader ->
|
||||
reader.mapNotNull {
|
||||
if (!it.isMms) {
|
||||
null
|
||||
} else if (it.isPaymentNotification) {
|
||||
SignalDatabase.payments.updateMessageWithPayment(it) as MmsMessageRecord
|
||||
} else {
|
||||
it as MmsMessageRecord
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecentPendingMessages(): MmsReader {
|
||||
val now = System.currentTimeMillis()
|
||||
val oneDayAgo = now.milliseconds - 1.days
|
||||
@@ -2695,6 +2735,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras
|
||||
)
|
||||
} else if (MessageTypes.isPinnedMessageUpdate(outboxType) && messageExtras != null) {
|
||||
OutgoingMessage.pinMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras
|
||||
)
|
||||
} else {
|
||||
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(body))
|
||||
@@ -2850,7 +2897,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
updateThread = updateThread,
|
||||
unarchive = true,
|
||||
poll = retrieved.poll,
|
||||
pollTerminate = retrieved.messageExtras?.pollTerminate
|
||||
pollTerminate = retrieved.messageExtras?.pollTerminate,
|
||||
pinnedMessage = retrieved.messageExtras?.pinnedMessage
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3181,6 +3229,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
if (message.messageExtras?.pinnedMessage != null) {
|
||||
if (hasSpecialType) {
|
||||
throw MmsException("Cannot insert message with multiple special types.")
|
||||
}
|
||||
type = type or MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
val earlyDeliveryReceipts: Map<RecipientId, Receipt> = earlyDeliveryReceiptCache.remove(message.sentTimeMillis)
|
||||
|
||||
if (earlyDeliveryReceipts.isNotEmpty()) {
|
||||
@@ -3296,7 +3352,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
updateThread = false,
|
||||
unarchive = false,
|
||||
poll = message.poll,
|
||||
pollTerminate = message.messageExtras?.pollTerminate
|
||||
pollTerminate = message.messageExtras?.pollTerminate,
|
||||
pinnedMessage = message.messageExtras?.pinnedMessage
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3405,7 +3462,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
updateThread: Boolean,
|
||||
unarchive: Boolean,
|
||||
poll: Poll? = null,
|
||||
pollTerminate: PollTerminate? = null
|
||||
pollTerminate: PollTerminate? = null,
|
||||
pinnedMessage: PinnedMessage?
|
||||
): kotlin.Pair<Long, Map<Attachment, AttachmentId>?> {
|
||||
val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf }
|
||||
val allAttachments: MutableList<Attachment> = mutableListOf()
|
||||
@@ -3471,6 +3529,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
if (pinnedMessage != null) {
|
||||
val pinnedUntil = if (pinnedMessage.pinDurationInSeconds == PIN_FOREVER) {
|
||||
PIN_FOREVER
|
||||
} else {
|
||||
System.currentTimeMillis() + pinnedMessage.pinDurationInSeconds.seconds.inWholeMilliseconds
|
||||
}
|
||||
val rows = db
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
PINNED_UNTIL to pinnedUntil,
|
||||
PINNING_MESSAGE_ID to messageId,
|
||||
PINNED_AT to System.currentTimeMillis()
|
||||
)
|
||||
.where("$ID = ?", pinnedMessage.pinnedMessageId)
|
||||
.run()
|
||||
|
||||
if (rows <= 0) {
|
||||
Log.w(TAG, "Failed to pin a message.")
|
||||
} else {
|
||||
enforcePinSizeLimit(threadId, RemoteConfig.pinLimit)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
messageId to insertedAttachments
|
||||
}
|
||||
|
||||
@@ -3487,6 +3570,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
threads.update(threadId, unarchive)
|
||||
}
|
||||
|
||||
if (pinnedMessage != null && pinnedMessage.pinDurationInSeconds != PIN_FOREVER) {
|
||||
AppDependencies.pinnedMessageManager.scheduleIfNecessary()
|
||||
}
|
||||
|
||||
return messageId to insertedAttachments
|
||||
}
|
||||
|
||||
@@ -3544,6 +3631,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
return rowsDeleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins the oldest pins if the thread exceeds the [limit]
|
||||
*/
|
||||
private fun enforcePinSizeLimit(threadId: Long, limit: Int) {
|
||||
val pinnedList = readableDatabase
|
||||
.select(PINNED_AT)
|
||||
.from(TABLE_NAME)
|
||||
.where("$THREAD_ID = ? AND $PINNED_UNTIL > 0", threadId)
|
||||
.orderBy("$PINNED_AT DESC")
|
||||
.run()
|
||||
.readToList { cursor -> cursor.requireLong(PINNED_AT) }
|
||||
|
||||
if (pinnedList.size > limit) {
|
||||
val oldestPin = pinnedList[limit]
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
PINNED_UNTIL to 0,
|
||||
PINNED_AT to 0
|
||||
)
|
||||
.where("$PINNED_AT > 0 AND $PINNED_AT <= ?", oldestPin)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: Long): Boolean {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
return deleteMessage(messageId, threadId)
|
||||
@@ -5084,6 +5196,33 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun getOldestExpiringPinnedMessageTimestamp(): MessageRecord? {
|
||||
val cursor = readableDatabase
|
||||
.select(*MMS_PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$PINNED_UNTIL > 0 AND $PINNED_UNTIL != ?", PIN_FOREVER)
|
||||
.orderBy("$PINNED_UNTIL ASC, $ID ASC")
|
||||
.limit(1)
|
||||
.run()
|
||||
|
||||
return mmsReaderFor(cursor).use { reader ->
|
||||
reader.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getPinnedMessagesBefore(time: Long): List<MessageRecord> {
|
||||
val cursor = readableDatabase
|
||||
.select(*MMS_PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$PINNED_UNTIL > 0 AND $PINNED_UNTIL <= ?", time)
|
||||
.orderBy("$PINNED_UNTIL ASC, $ID ASC")
|
||||
.run()
|
||||
|
||||
return mmsReaderFor(cursor).use { reader ->
|
||||
reader.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessagesForNotificationState(stickyThreads: Collection<StickyThread>): Cursor {
|
||||
val stickyQuery = StringBuilder()
|
||||
|
||||
@@ -5306,6 +5445,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun unpinMessage(messageId: Long, threadId: Long) {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(PINNED_UNTIL to 0)
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected fun <D : Document<I>?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class<D>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
@@ -5463,6 +5611,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.PINNED_MESSAGE -> MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.GROUP_UPDATE -> {
|
||||
val isOnlyGroupLeave = this.groupContext?.let { GroupV2UpdateMessageUtil.isJustAGroupLeave(it) } ?: false
|
||||
|
||||
@@ -6005,6 +6154,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
val originalMessageId: MessageId? = cursor.requireLong(ORIGINAL_MESSAGE_ID).let { if (it == 0L) null else MessageId(it) }
|
||||
val editCount = cursor.requireInt(REVISION_NUMBER)
|
||||
val isRead = cursor.requireBoolean(READ)
|
||||
val pinnedUntil = cursor.requireLong(PINNED_UNTIL)
|
||||
val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS)
|
||||
val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
|
||||
@@ -6099,6 +6249,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
originalMessageId,
|
||||
editCount,
|
||||
isRead,
|
||||
pinnedUntil,
|
||||
messageExtras
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,5 +48,8 @@ enum class MessageType {
|
||||
END_SESSION,
|
||||
|
||||
/** A poll has ended **/
|
||||
POLL_TERMINATE
|
||||
POLL_TERMINATE,
|
||||
|
||||
/** A message has been pinned **/
|
||||
PINNED_MESSAGE
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ public interface MessageTypes {
|
||||
long SPECIAL_TYPE_BLOCKED = 0xA00000000L;
|
||||
long SPECIAL_TYPE_UNBLOCKED = 0xB00000000L;
|
||||
long SPECIAL_TYPE_POLL_TERMINATE = 0xC00000000L;
|
||||
long SPECIAL_TYPE_PINNED_MESSAGE = 0xD00000000L;
|
||||
|
||||
long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
|
||||
|
||||
@@ -170,6 +171,10 @@ public interface MessageTypes {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_POLL_TERMINATE;
|
||||
}
|
||||
|
||||
static boolean isPinnedMessageUpdate(long type) {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PINNED_MESSAGE;
|
||||
}
|
||||
|
||||
static boolean isDraftMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ public final class ThreadBodyUtil {
|
||||
} else if (MessageRecordUtil.hasPollTerminate(record)) {
|
||||
return record.getFromRecipient().isSelf() ? new ThreadBody(context.getString(R.string.Poll__you_poll_end, record.getMessageExtras().pollTerminate.question))
|
||||
: new ThreadBody(context.getString(R.string.Poll__poll_end, record.getFromRecipient().getDisplayName(context), record.getMessageExtras().pollTerminate.question));
|
||||
} else if (MessageRecordUtil.hasPinnedMessageUpdate(record)) {
|
||||
return record.getFromRecipient().isSelf() ? new ThreadBody(context.getString(R.string.PinnedMessage__you_pinned_a_message))
|
||||
: new ThreadBody(context.getString(R.string.PinnedMessage__s_pinned_a_message, record.getFromRecipient().getDisplayName(context)));
|
||||
}
|
||||
|
||||
boolean hasImage = false;
|
||||
|
||||
@@ -150,6 +150,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V292_AddPollTables
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V294_RemoveLastResortKeyTupleColumnConstraintMigration
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V295_AddLastRestoreKeyTypeTableIfMissingMigration
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V296_RemovePollVoteConstraint
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V297_AddPinnedMessageColumns
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
|
||||
|
||||
/**
|
||||
@@ -306,10 +307,11 @@ object SignalDatabaseMigrations {
|
||||
// 293 to V293_LastResortKeyTupleTableMigration, - removed due to crashing on some devices.
|
||||
294 to V294_RemoveLastResortKeyTupleColumnConstraintMigration,
|
||||
295 to V295_AddLastRestoreKeyTypeTableIfMissingMigration,
|
||||
296 to V296_RemovePollVoteConstraint
|
||||
296 to V296_RemovePollVoteConstraint,
|
||||
297 to V297_AddPinnedMessageColumns
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 296
|
||||
const val DATABASE_VERSION = 297
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Adds the columns and indexes necessary for pinned messages
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V297_AddPinnedMessageColumns : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN pinned_until INTEGER DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN pinning_message_id INTEGER DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN pinned_at INTEGER DEFAULT 0")
|
||||
|
||||
db.execSQL("CREATE INDEX message_pinned_until_index ON message (pinned_until)")
|
||||
db.execSQL("CREATE INDEX message_pinned_at_index ON message (pinned_at)")
|
||||
}
|
||||
}
|
||||
@@ -264,4 +264,8 @@ public abstract class DisplayRecord {
|
||||
public boolean isPollTerminate() {
|
||||
return MessageTypes.isPollTerminate(type);
|
||||
}
|
||||
|
||||
public boolean isPinnedMessageUpdate() {
|
||||
return MessageTypes.isPinnedMessageUpdate(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class InMemoryMessageRecord extends MessageRecord {
|
||||
-1,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
null);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
private final long receiptTimestamp;
|
||||
private final MessageId originalMessageId;
|
||||
private final int revisionNumber;
|
||||
private final long pinnedUntil;
|
||||
private final MessageExtras messageExtras;
|
||||
|
||||
protected Boolean isJumboji = null;
|
||||
@@ -135,6 +136,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
long receiptTimestamp,
|
||||
@Nullable MessageId originalMessageId,
|
||||
int revisionNumber,
|
||||
long pinnedUntil,
|
||||
@Nullable MessageExtras messageExtras)
|
||||
{
|
||||
super(body, fromRecipient, toRecipient, dateSent, dateReceived,
|
||||
@@ -156,6 +158,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
this.receiptTimestamp = receiptTimestamp;
|
||||
this.originalMessageId = originalMessageId;
|
||||
this.revisionNumber = revisionNumber;
|
||||
this.pinnedUntil = pinnedUntil;
|
||||
this.messageExtras = messageExtras;
|
||||
}
|
||||
|
||||
@@ -297,6 +300,9 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
} else if (MessageRecordUtil.hasPollTerminate(this)) {
|
||||
return getFromRecipient().isSelf() ? staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_you_ended_the_poll, messageExtras.pollTerminate.question), Glyph.POLL)
|
||||
: staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_ended_the_poll, getFromRecipient().getDisplayName(context), messageExtras.pollTerminate.question), Glyph.POLL);
|
||||
} else if (MessageRecordUtil.hasPinnedMessageUpdate(this)) {
|
||||
return getFromRecipient().isSelf() ? staticUpdateDescriptionWithExpiration(context.getString(R.string.PinnedMessage__you_pinned_a_message), Glyph.PIN)
|
||||
: staticUpdateDescriptionWithExpiration(context.getString(R.string.PinnedMessage__s_pinned_a_message, getFromRecipient().getDisplayName(context)), Glyph.PIN);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -740,7 +746,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() ||
|
||||
isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() ||
|
||||
isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() ||
|
||||
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate();
|
||||
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate() || isPinnedMessageUpdate();
|
||||
}
|
||||
|
||||
public boolean isMediaPending() {
|
||||
@@ -775,6 +781,10 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return MessageTypes.isChatSessionRefresh(type);
|
||||
}
|
||||
|
||||
public long getPinnedUntil() {
|
||||
return pinnedUntil;
|
||||
}
|
||||
|
||||
public boolean isInMemoryMessageRecord() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -120,12 +120,13 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
@Nullable MessageId originalMessageId,
|
||||
int revisionNumber,
|
||||
boolean isRead,
|
||||
long pinnedUntil,
|
||||
@Nullable MessageExtras messageExtras)
|
||||
{
|
||||
super(id, body, fromRecipient, fromDeviceId, toRecipient,
|
||||
dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt,
|
||||
mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt,
|
||||
unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, messageExtras);
|
||||
unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, messageExtras);
|
||||
|
||||
this.slideDeck = slideDeck;
|
||||
this.quote = quote;
|
||||
@@ -338,7 +339,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withoutQuote() {
|
||||
@@ -346,7 +347,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withAttachments(@NonNull List<DatabaseAttachment> attachments) {
|
||||
@@ -368,7 +369,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) {
|
||||
@@ -376,7 +377,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +386,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) {
|
||||
@@ -393,7 +394,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringStoriesManager
|
||||
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager
|
||||
import org.thoughtcrime.securesms.service.PinnedMessageManager
|
||||
import org.thoughtcrime.securesms.service.ScheduledMessageManager
|
||||
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager
|
||||
@@ -214,6 +215,11 @@ object AppDependencies {
|
||||
provider.provideScheduledMessageManager()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val pinnedMessageManager: PinnedMessageManager by lazy {
|
||||
provider.providePinnedMessageManager()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val androidCallAudioManager: AudioManagerCompat by lazy {
|
||||
provider.provideAndroidCallAudioManager()
|
||||
@@ -430,6 +436,7 @@ object AppDependencies {
|
||||
fun provideDeadlockDetector(): DeadlockDetector
|
||||
fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations
|
||||
fun provideScheduledMessageManager(): ScheduledMessageManager
|
||||
fun providePinnedMessageManager(): PinnedMessageManager
|
||||
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
|
||||
fun provideBillingApi(): BillingApi
|
||||
fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi
|
||||
|
||||
@@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
|
||||
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
|
||||
import org.thoughtcrime.securesms.service.PinnedMessageManager;
|
||||
import org.thoughtcrime.securesms.service.ScheduledMessageManager;
|
||||
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
@@ -271,6 +272,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
return new ScheduledMessageManager(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PinnedMessageManager providePinnedMessageManager() {
|
||||
return new PinnedMessageManager(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) {
|
||||
Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT);
|
||||
|
||||
@@ -97,6 +97,7 @@ object SignalSymbols {
|
||||
CHEVRON_SQUARE_RIGHT('\uE02D'),
|
||||
CHEVRON_SQUARE_UP('\uE02E'),
|
||||
CHEVRON_SQUARE_DOWN('\uE02F'),
|
||||
CREDIT_CARD('\uE127'),
|
||||
DROPDOWN_DOWN('\uE07F'),
|
||||
DROPDOWN_UP('\uE080'),
|
||||
DROPDOWN_TRIANGLE_DOWN('\uE082'),
|
||||
@@ -163,6 +164,7 @@ object SignalSymbols {
|
||||
PHONE('\uE063'),
|
||||
PHONE_FILL('\uE064'),
|
||||
PHOTO('\uE065'),
|
||||
PIN('\uE12E'),
|
||||
PLAY('\uE067'),
|
||||
PLUS('\u002B'),
|
||||
PLUS_CIRCLE('\u2295'),
|
||||
|
||||
@@ -286,6 +286,7 @@ public class IndividualSendJob extends PushSendJob {
|
||||
SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message);
|
||||
SignalServiceDataMessage.Payment payment = getPayment(message);
|
||||
List<BodyRange> bodyRanges = getBodyRanges(message);
|
||||
SignalServiceDataMessage.PinnedMessage pinnedMessage = getPinnedMessage(message);
|
||||
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
|
||||
.withBody(message.getBody())
|
||||
.withAttachments(serviceAttachments)
|
||||
@@ -301,7 +302,8 @@ public class IndividualSendJob extends PushSendJob {
|
||||
.asExpirationUpdate(message.isExpirationUpdate())
|
||||
.asEndSessionMessage(message.isEndSession())
|
||||
.withPayment(payment)
|
||||
.withBodyRanges(bodyRanges);
|
||||
.withBodyRanges(bodyRanges)
|
||||
.withPinnedMessage(pinnedMessage);
|
||||
|
||||
if (message.getParentStoryId() != null) {
|
||||
try {
|
||||
|
||||
@@ -289,6 +289,7 @@ public final class JobManagerFactories {
|
||||
put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory());
|
||||
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
|
||||
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
||||
put(UnpinMessageJob.KEY, new UnpinMessageJob.Factory());
|
||||
put(UploadAttachmentToArchiveJob.KEY, new UploadAttachmentToArchiveJob.Factory());
|
||||
|
||||
// Migrations
|
||||
|
||||
@@ -286,6 +286,7 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
List<BodyRange> bodyRanges = getBodyRanges(message);
|
||||
Optional<SignalServiceDataMessage.PollCreate> pollCreate = getPollCreate(message);
|
||||
Optional<SignalServiceDataMessage.PollTerminate> pollTerminate = getPollTerminate(message);
|
||||
SignalServiceDataMessage.PinnedMessage pinnedMessage = getPinnedMessage(message);
|
||||
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
|
||||
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
|
||||
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
|
||||
@@ -368,7 +369,8 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
.withMentions(mentions)
|
||||
.withBodyRanges(bodyRanges)
|
||||
.withPollCreate(pollCreate.orElse(null))
|
||||
.withPollTerminate(pollTerminate.orElse(null));
|
||||
.withPollTerminate(pollTerminate.orElse(null))
|
||||
.withPinnedMessage(pinnedMessage);
|
||||
|
||||
if (message.getParentStoryId() != null) {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -30,6 +29,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactModelMapper;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
@@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
@@ -48,7 +49,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil;
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
@@ -60,9 +60,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.ImageCompressionUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress;
|
||||
@@ -77,10 +75,8 @@ import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulRespons
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer;
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
@@ -90,7 +86,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -530,6 +525,19 @@ public abstract class PushSendJob extends SendJob {
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
protected @Nullable SignalServiceDataMessage.PinnedMessage getPinnedMessage(OutgoingMessage message) {
|
||||
if (message.getMessageExtras() == null || message.getMessageExtras().pinnedMessage == null || ACI.parseOrNull(message.getMessageExtras().pinnedMessage.targetAuthorAci) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PinnedMessage pinnedMessage = message.getMessageExtras().pinnedMessage;
|
||||
if (pinnedMessage.pinDurationInSeconds == MessageTable.PIN_FOREVER) {
|
||||
return new SignalServiceDataMessage.PinnedMessage(ACI.parseOrNull(pinnedMessage.targetAuthorAci), pinnedMessage.targetTimestamp, null, true);
|
||||
} else {
|
||||
return new SignalServiceDataMessage.PinnedMessage(ACI.parseOrNull(pinnedMessage.targetAuthorAci), pinnedMessage.targetTimestamp, (int) pinnedMessage.pinDurationInSeconds, null);
|
||||
}
|
||||
}
|
||||
|
||||
protected void rotateSenderCertificateIfNecessary() throws IOException {
|
||||
try {
|
||||
Collection<CertificateType> requiredCertificateTypes = SignalStore.phoneNumberPrivacy()
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobs.protos.UnpinJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.GroupSendUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.GroupUtil
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Companion.newBuilder
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Job to unpin a message sent either to 1:1 or group chat
|
||||
*/
|
||||
class UnpinMessageJob(
|
||||
private val messageId: Long,
|
||||
private val recipientIds: MutableList<Long>,
|
||||
private val initialRecipientCount: Int,
|
||||
parameters: Parameters
|
||||
) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY: String = "UnpinMessageJob"
|
||||
private val TAG = Log.tag(UnpinMessageJob::class.java)
|
||||
|
||||
fun create(messageId: Long): UnpinMessageJob? {
|
||||
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
|
||||
if (message == null) {
|
||||
Log.w(TAG, "Unable to find corresponding message")
|
||||
return null
|
||||
}
|
||||
|
||||
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
|
||||
if (conversationRecipient == null) {
|
||||
Log.w(TAG, "We have a message, but couldn't find the thread!")
|
||||
return null
|
||||
}
|
||||
|
||||
val recipients = if (conversationRecipient.isGroup) {
|
||||
conversationRecipient.participantIds.filter { it != Recipient.self().id }.map { it.toLong() }
|
||||
} else {
|
||||
listOf(conversationRecipient.id.toLong())
|
||||
}
|
||||
|
||||
return UnpinMessageJob(
|
||||
messageId = messageId,
|
||||
recipientIds = recipients.toMutableList(),
|
||||
initialRecipientCount = recipients.size,
|
||||
parameters = Parameters.Builder()
|
||||
.setQueue(conversationRecipient.id.toQueueKey())
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setLifespan(1.days.inWholeMilliseconds)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray {
|
||||
return UnpinJobData(messageId, recipientIds, initialRecipientCount).encode()
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String {
|
||||
return KEY
|
||||
}
|
||||
|
||||
override fun run(): Result {
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
Log.w(TAG, "Not registered. Skipping.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
|
||||
if (message == null) {
|
||||
Log.w(TAG, "Unable to find corresponding message")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
|
||||
if (conversationRecipient == null) {
|
||||
Log.w(TAG, "We have a message, but couldn't find the thread!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val targetAuthor = message.fromRecipient
|
||||
if (targetAuthor == null || !targetAuthor.hasServiceId) {
|
||||
Log.w(TAG, "Unable to find target author")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val targetSentTimestamp = message.dateSent
|
||||
|
||||
val recipients = Recipient.resolvedList(recipientIds.filter { it != Recipient.self().id.toLong() }.map { RecipientId.from(it) })
|
||||
val registered = RecipientUtil.getEligibleForSending(recipients)
|
||||
val unregistered = recipients - registered.toSet()
|
||||
val completions: List<Recipient> = deliver(conversationRecipient, registered, message.threadId, targetAuthor, targetSentTimestamp)
|
||||
|
||||
recipientIds.removeAll(unregistered.map { it.id.toLong() })
|
||||
recipientIds.removeAll(completions.map { it.id.toLong() })
|
||||
|
||||
Log.i(TAG, "Completed now: " + completions.size + ", Remaining: " + recipientIds.size)
|
||||
|
||||
if (recipientIds.isNotEmpty()) {
|
||||
Log.w(TAG, "Still need to send to " + recipientIds.size + " recipients. Retrying.")
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun deliver(conversationRecipient: Recipient, destinations: List<Recipient>, threadId: Long, targetAuthor: Recipient, targetSentTimestamp: Long): List<Recipient> {
|
||||
val dataMessageBuilder = newBuilder()
|
||||
.withTimestamp(System.currentTimeMillis())
|
||||
.withUnpinnedMessage(
|
||||
SignalServiceDataMessage.UnpinnedMessage(
|
||||
targetAuthor = targetAuthor.requireServiceId(),
|
||||
targetSentTimestamp = targetSentTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
if (conversationRecipient.isGroup) {
|
||||
GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush())
|
||||
}
|
||||
|
||||
val dataMessage = dataMessageBuilder.build()
|
||||
|
||||
val results = GroupSendUtil.sendResendableDataMessage(
|
||||
context,
|
||||
conversationRecipient.groupId.map { obj: GroupId -> obj.requireV2() }.orElse(null),
|
||||
null,
|
||||
destinations,
|
||||
false,
|
||||
ContentHint.RESENDABLE,
|
||||
MessageId(messageId),
|
||||
dataMessage,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
)
|
||||
|
||||
val result = GroupSendJobHelper.getCompletedSends(destinations, results)
|
||||
|
||||
for (unregistered in result.unregistered) {
|
||||
SignalDatabase.recipients.markUnregistered(unregistered)
|
||||
}
|
||||
|
||||
if (result.completed.isNotEmpty() || destinations.isEmpty()) {
|
||||
SignalDatabase.messages.unpinMessage(
|
||||
messageId = messageId,
|
||||
threadId = threadId
|
||||
)
|
||||
}
|
||||
|
||||
return result.completed
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
if (recipientIds.size < initialRecipientCount) {
|
||||
Log.w(TAG, "Only sent unpinned to " + recipientIds.size + "/" + initialRecipientCount + " recipients.")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to send to all recipients.")
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<UnpinMessageJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): UnpinMessageJob {
|
||||
val data = UnpinJobData.ADAPTER.decode(serializedData!!)
|
||||
|
||||
return UnpinMessageJob(
|
||||
messageId = data.messageId,
|
||||
recipientIds = data.recipients.toMutableList(),
|
||||
initialRecipientCount = data.initialRecipientCount,
|
||||
parameters = parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import java.util.List;
|
||||
public class UiHintValues extends SignalStoreValues {
|
||||
|
||||
private static final int NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD = 3;
|
||||
private static final int HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD = 3;
|
||||
|
||||
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
|
||||
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
|
||||
@@ -29,6 +30,7 @@ public class UiHintValues extends SignalStoreValues {
|
||||
private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet";
|
||||
private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet";
|
||||
private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning";
|
||||
private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet";
|
||||
|
||||
UiHintValues(@NonNull KeyValueStore store) {
|
||||
super(store);
|
||||
@@ -218,4 +220,16 @@ public class UiHintValues extends SignalStoreValues {
|
||||
public void markDismissedSaveStorageWarning() {
|
||||
putBoolean(HAS_DISMISSED_SAVE_STORAGE_WARNING, true);
|
||||
}
|
||||
|
||||
public boolean shouldDisplayPinnedSheet() {
|
||||
return getSeenPinnedSheetCount() < HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD;
|
||||
}
|
||||
|
||||
public void incrementSeenPinnedSheetCount() {
|
||||
putInteger(HAS_SEEN_PINNED_MESSAGE_SHEET, getSeenPinnedSheetCount() + 1);
|
||||
}
|
||||
|
||||
private int getSeenPinnedSheetCount() {
|
||||
return getInteger(HAS_SEEN_PINNED_MESSAGE_SHEET, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,10 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMessageDetailsFragmentDismissed()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.contactshare.ContactModelMapper
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.SecurityEvent
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable.InsertResult
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException
|
||||
@@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
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.database.model.toBodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -178,6 +180,8 @@ object DataMessageProcessor {
|
||||
message.pollCreate != null -> insertResult = handlePollCreate(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime)
|
||||
message.pollTerminate != null -> insertResult = handlePollTerminate(context, envelope, metadata, message, senderRecipient, earlyMessageCacheEntry, threadRecipient, groupId, receivedTime)
|
||||
message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry)
|
||||
message.pinMessage != null -> insertResult = handlePinMessage(envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, earlyMessageCacheEntry)
|
||||
message.unpinMessage != null -> messageId = handleUnpinMessage(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry)
|
||||
}
|
||||
|
||||
messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) }
|
||||
@@ -1247,6 +1251,150 @@ object DataMessageProcessor {
|
||||
return messageId
|
||||
}
|
||||
|
||||
fun handlePinMessage(
|
||||
envelope: Envelope,
|
||||
metadata: EnvelopeMetadata,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
threadRecipient: Recipient,
|
||||
groupId: GroupId.V2?,
|
||||
receivedTime: Long,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null
|
||||
): InsertResult? {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Pinned message not allowed due to remote config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val pinMessage = message.pinMessage!!
|
||||
log(envelope.timestamp!!, "[handlePinMessage] Pin message for " + pinMessage.targetSentTimestamp)
|
||||
|
||||
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
|
||||
|
||||
val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(pinMessage.targetAuthorAciBinary!!)
|
||||
if (targetAuthorServiceId.isUnknown) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Unknown target author! Ignoring the message.")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetAuthor = Recipient.externalPush(targetAuthorServiceId)
|
||||
|
||||
val targetMessage: MmsMessageRecord? = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp!!, targetAuthor.id) as? MmsMessageRecord
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Could not find matching message! Putting it in the early message cache. timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(targetAuthor.id, pinMessage.targetSentTimestamp!!, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetMessage.isRemoteDelete) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Found a matching message, but it's flagged as remotely deleted. timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
|
||||
if (targetThread == null) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Could not find a thread for the message! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull()
|
||||
if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Sender is not in the group! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (groupRecord == null && senderRecipient.id != threadRecipient.id && Recipient.self().id != senderRecipient.id) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Sender is not a part of the 1:1 thread! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val duration = if (pinMessage.pinDurationForever == true) MessageTable.PIN_FOREVER else pinMessage.pinDurationSeconds!!.toLong()
|
||||
val pinnedMessage = IncomingMessage(
|
||||
type = MessageType.PINNED_MESSAGE,
|
||||
from = senderRecipient.id,
|
||||
sentTimeMillis = envelope.timestamp!!,
|
||||
serverTimeMillis = envelope.serverTimestamp!!,
|
||||
receivedTimeMillis = receivedTime,
|
||||
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
|
||||
groupId = groupId,
|
||||
isUnidentified = metadata.sealedSender,
|
||||
serverGuid = UuidUtil.getStringUUID(envelope.serverGuid, envelope.serverGuidBinary),
|
||||
messageExtras = MessageExtras(pinnedMessage = PinnedMessage(pinnedMessageId = targetMessage.id, targetAuthorAci = pinMessage.targetAuthorAciBinary!!, targetTimestamp = pinMessage.targetSentTimestamp!!, pinDurationInSeconds = duration))
|
||||
)
|
||||
|
||||
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pinnedMessage).orNull()
|
||||
|
||||
return if (insertResult != null) {
|
||||
log(envelope.timestamp!!, "Inserted a pinned message update at ${insertResult.messageId}")
|
||||
insertResult
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUnpinMessage(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
threadRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null
|
||||
): MessageId? {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Unpinning message is not allowed due to remote config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val unpinMessage = message.unpinMessage!!
|
||||
log(envelope.timestamp!!, "[handleUnpinMessage] Unpin message for ${unpinMessage.targetSentTimestamp}")
|
||||
|
||||
val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(unpinMessage.targetAuthorAciBinary!!)
|
||||
if (targetAuthorServiceId.isUnknown) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Unknown target author! Ignoring the message.")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetAuthor = Recipient.externalPush(targetAuthorServiceId)
|
||||
|
||||
val targetMessage: MmsMessageRecord? = SignalDatabase.messages.getMessageFor(unpinMessage.targetSentTimestamp!!, targetAuthor.id) as? MmsMessageRecord
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Could not find matching message! Putting it in the early message cache. timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(targetAuthor.id, unpinMessage.targetSentTimestamp!!, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetMessage.isRemoteDelete) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Found a matching message, but it's flagged as remotely deleted. timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
|
||||
if (targetThread == null) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Could not find a thread for the message! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull()
|
||||
if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not in the group! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (groupRecord == null && senderRecipient.id != threadRecipient.id && Recipient.self().id != senderRecipient.id) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not a part of the 1:1 thread! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
SignalDatabase.messages.unpinMessage(targetMessage.id, targetMessage.threadId)
|
||||
|
||||
return MessageId(targetMessage.id)
|
||||
}
|
||||
|
||||
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
|
||||
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
|
||||
|
||||
|
||||
@@ -499,6 +499,17 @@ data class OutgoingMessage(
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pinMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, messageExtras: MessageExtras): OutgoingMessage {
|
||||
return OutgoingMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = sentTimeMillis,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras,
|
||||
isSecure = true
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun quickReply(
|
||||
threadRecipient: Recipient,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.service
|
||||
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Manages waking up and unpinning pinned messages at the correct time
|
||||
*/
|
||||
class PinnedMessageManager(
|
||||
val application: Application
|
||||
) : TimedEventManager<PinnedMessageManager.Event>(application, "PinnedMessagesManager") {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessageManager::class.java)
|
||||
}
|
||||
|
||||
private val messagesTable = SignalDatabase.messages
|
||||
|
||||
init {
|
||||
scheduleIfNecessary()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getNextClosestEvent(): Event? {
|
||||
val oldestMessage: MmsMessageRecord? = messagesTable.getOldestExpiringPinnedMessageTimestamp() as? MmsMessageRecord
|
||||
|
||||
if (oldestMessage == null) {
|
||||
cancelAlarm(application, PinnedMessagesAlarm::class.java)
|
||||
return null
|
||||
}
|
||||
|
||||
val delay = (oldestMessage.pinnedUntil - System.currentTimeMillis()).coerceAtLeast(0)
|
||||
Log.i(TAG, "The next pinned message needs to be unpinned in $delay ms.")
|
||||
|
||||
return Event(delay, oldestMessage.toRecipient.id, oldestMessage.threadId)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun executeEvent(event: Event) {
|
||||
val pinnedMessagesToUnpin = messagesTable.getPinnedMessagesBefore(System.currentTimeMillis())
|
||||
for (record in pinnedMessagesToUnpin) {
|
||||
messagesTable.unpinMessage(messageId = record.id, threadId = record.threadId)
|
||||
// TODO(michelle): Send sync message to linked device to unpin message (done to ensure consistency)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getDelayForEvent(event: Event): Long = event.delay
|
||||
|
||||
@WorkerThread
|
||||
override fun scheduleAlarm(application: Application, event: Event, delay: Long) {
|
||||
val conversationIntent = ConversationIntents.createBuilderSync(application, event.recipientId, event.threadId).build()
|
||||
|
||||
trySetExactAlarm(
|
||||
application,
|
||||
System.currentTimeMillis() + delay,
|
||||
PinnedMessagesAlarm::class.java,
|
||||
PendingIntent.getActivity(application, 0, conversationIntent, PendingIntentFlags.mutable())
|
||||
)
|
||||
}
|
||||
|
||||
data class Event(val delay: Long, val recipientId: RecipientId, val threadId: Long)
|
||||
|
||||
class PinnedMessagesAlarm : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessagesAlarm::class.java)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
Log.d(TAG, "onReceive()")
|
||||
AppDependencies.pinnedMessageManager.scheduleIfNecessary()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,32 +23,23 @@ fun MessageRecord.isMediaMessage(): Boolean {
|
||||
slideDeck.stickerSlide == null
|
||||
}
|
||||
|
||||
fun MessageRecord.hasNonTextSlide(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.slides.any { slide -> slide !is TextSlide }
|
||||
fun MessageRecord.hasNonTextSlide(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.slides.any { slide -> slide !is TextSlide }
|
||||
|
||||
fun MessageRecord.hasSticker(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.stickerSlide != null
|
||||
fun MessageRecord.hasSticker(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.stickerSlide != null
|
||||
|
||||
fun MessageRecord.hasSharedContact(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).sharedContacts.isNotEmpty()
|
||||
fun MessageRecord.hasSharedContact(): Boolean = isMms && (this as MmsMessageRecord).sharedContacts.isNotEmpty()
|
||||
|
||||
fun MessageRecord.hasLocation(): Boolean =
|
||||
isMms && ((this as MmsMessageRecord).slideDeck.slides).any { slide -> slide.hasLocation() }
|
||||
fun MessageRecord.hasLocation(): Boolean = isMms && ((this as MmsMessageRecord).slideDeck.slides).any { slide -> slide.hasLocation() }
|
||||
|
||||
fun MessageRecord.hasAudio(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.audioSlide != null
|
||||
fun MessageRecord.hasAudio(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.audioSlide != null
|
||||
|
||||
fun MessageRecord.isCaptionlessMms(context: Context): Boolean =
|
||||
isMms && isDisplayBodyEmpty(context) && (this as MmsMessageRecord).slideDeck.textSlide == null
|
||||
fun MessageRecord.isCaptionlessMms(context: Context): Boolean = isMms && isDisplayBodyEmpty(context) && (this as MmsMessageRecord).slideDeck.textSlide == null
|
||||
|
||||
fun MessageRecord.hasThumbnail(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null
|
||||
fun MessageRecord.hasThumbnail(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null
|
||||
|
||||
fun MessageRecord.isStoryReaction(): Boolean =
|
||||
isMms && MessageTypes.isStoryReaction(type)
|
||||
fun MessageRecord.isStoryReaction(): Boolean = isMms && MessageTypes.isStoryReaction(type)
|
||||
|
||||
fun MessageRecord.isStory(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).storyType.isStory
|
||||
fun MessageRecord.isStory(): Boolean = isMms && (this as MmsMessageRecord).storyType.isStory
|
||||
|
||||
fun MessageRecord.isBorderless(context: Context): Boolean {
|
||||
return isCaptionlessMms(context) &&
|
||||
@@ -56,8 +47,7 @@ fun MessageRecord.isBorderless(context: Context): Boolean {
|
||||
(this as MmsMessageRecord).slideDeck.thumbnailSlide?.isBorderless == true
|
||||
}
|
||||
|
||||
fun MessageRecord.hasNoBubble(context: Context): Boolean =
|
||||
hasSticker() || isBorderless(context) || (isTextOnly(context) && isJumbomoji(context) && (messageRanges?.ranges?.isEmpty() ?: true))
|
||||
fun MessageRecord.hasNoBubble(context: Context): Boolean = hasSticker() || isBorderless(context) || (isTextOnly(context) && isJumbomoji(context) && (messageRanges?.ranges?.isEmpty() ?: true))
|
||||
|
||||
fun MessageRecord.hasOnlyThumbnail(context: Context): Boolean {
|
||||
return hasThumbnail() &&
|
||||
@@ -69,11 +59,9 @@ fun MessageRecord.hasOnlyThumbnail(context: Context): Boolean {
|
||||
!isViewOnceMessage()
|
||||
}
|
||||
|
||||
fun MessageRecord.hasDocument(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.documentSlide != null
|
||||
fun MessageRecord.hasDocument(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.documentSlide != null
|
||||
|
||||
fun MessageRecord.isViewOnceMessage(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).isViewOnce
|
||||
fun MessageRecord.isViewOnceMessage(): Boolean = isMms && (this as MmsMessageRecord).isViewOnce
|
||||
|
||||
fun MessageRecord.hasExtraText(): Boolean {
|
||||
val hasTextSlide = isMms && (this as MmsMessageRecord).slideDeck.textSlide != null
|
||||
@@ -82,24 +70,19 @@ fun MessageRecord.hasExtraText(): Boolean {
|
||||
return hasTextSlide || hasOverflowText
|
||||
}
|
||||
|
||||
fun MessageRecord.hasQuote(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).quote != null
|
||||
fun MessageRecord.hasQuote(): Boolean = isMms && (this as MmsMessageRecord).quote != null
|
||||
|
||||
fun MessageRecord.getQuote(): Quote? =
|
||||
if (isMms) {
|
||||
(this as MmsMessageRecord).quote
|
||||
} else {
|
||||
null
|
||||
}
|
||||
fun MessageRecord.getQuote(): Quote? = if (isMms) {
|
||||
(this as MmsMessageRecord).quote
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun MessageRecord.hasLinkPreview(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).linkPreviews.isNotEmpty()
|
||||
fun MessageRecord.hasLinkPreview(): Boolean = isMms && (this as MmsMessageRecord).linkPreviews.isNotEmpty()
|
||||
|
||||
fun MessageRecord.hasTextSlide(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.textSlide != null && this.slideDeck.textSlide?.uri != null
|
||||
fun MessageRecord.hasTextSlide(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.textSlide != null && this.slideDeck.textSlide?.uri != null
|
||||
|
||||
fun MessageRecord.requireTextSlide(): TextSlide =
|
||||
requireNotNull((this as MmsMessageRecord).slideDeck.textSlide)
|
||||
fun MessageRecord.requireTextSlide(): TextSlide = requireNotNull((this as MmsMessageRecord).slideDeck.textSlide)
|
||||
|
||||
fun MessageRecord.hasPoll(): Boolean = isMms && (this as MmsMessageRecord).poll != null
|
||||
|
||||
@@ -107,6 +90,8 @@ fun MessageRecord.getPoll(): PollRecord? = if (isMms) (this as MmsMessageRecord)
|
||||
|
||||
fun MessageRecord.hasPollTerminate(): Boolean = this.isPollTerminate && this.messageExtras != null && this.messageExtras!!.pollTerminate != null
|
||||
|
||||
fun MessageRecord.hasPinnedMessageUpdate(): Boolean = this.isPinnedMessageUpdate && this.messageExtras != null && this.messageExtras!!.pinnedMessage != null
|
||||
|
||||
fun MessageRecord.hasBigImageLinkPreview(context: Context): Boolean {
|
||||
if (!hasLinkPreview()) {
|
||||
return false
|
||||
|
||||
@@ -1205,5 +1205,29 @@ object RemoteConfig {
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("pinLimit")
|
||||
val pinLimit: Int by remoteInt(
|
||||
key = "global.pinnedMessageLimit",
|
||||
defaultValue = 3,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("receivePinnedMessages")
|
||||
val receivePinnedMessages: Boolean by remoteBoolean(
|
||||
key = "android.receivePinnedMessages",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("sendPinnedMessages")
|
||||
val sendPinnedMessages: Boolean by remoteBoolean(
|
||||
key = "android.sendPinnedMessages",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
// endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user