Add basic pinned message support.

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

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)
}

View File

@@ -48,5 +48,8 @@ enum class MessageType {
END_SESSION,
/** A poll has ended **/
POLL_TERMINATE
POLL_TERMINATE,
/** A message has been pinned **/
PINNED_MESSAGE
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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)")
}
}

View File

@@ -264,4 +264,8 @@ public abstract class DisplayRecord {
public boolean isPollTerminate() {
return MessageTypes.isPollTerminate(type);
}
public boolean isPinnedMessageUpdate() {
return MessageTypes.isPinnedMessageUpdate(type);
}
}

View File

@@ -59,6 +59,7 @@ public class InMemoryMessageRecord extends MessageRecord {
-1,
null,
0,
0,
null);
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);

View File

@@ -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'),

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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
)
}
}
}

View File

@@ -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);
}
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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
}