mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-08 09:18:39 +01:00
Add basic pinned message support.
This commit is contained in:
committed by
jeffrey-signal
parent
22701da765
commit
80598d42cc
+2
@@ -356,5 +356,7 @@ class V2ConversationItemShapeTest {
|
||||
override fun onViewPollClicked(messageId: Long) = Unit
|
||||
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -121,6 +121,7 @@ class ConversationElementGenerator {
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
null
|
||||
)
|
||||
|
||||
|
||||
+4
@@ -350,5 +350,9 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,6 +1268,10 @@
|
||||
android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm"
|
||||
android:exported="false"/>
|
||||
|
||||
<receiver
|
||||
android:name=".service.PinnedMessageManager$PinnedMessagesAlarm"
|
||||
android:exported="false"/>
|
||||
|
||||
<receiver
|
||||
android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm"
|
||||
android:exported="false"/>
|
||||
|
||||
@@ -148,5 +148,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onViewResultsClicked(long pollId);
|
||||
void onViewPollClicked(long messageId);
|
||||
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
|
||||
void onViewPinnedMessage(long messageId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
private ExpirationTimerView timerView;
|
||||
private ImageView insecureIndicatorView;
|
||||
private DeliveryStatusView deliveryStatusView;
|
||||
private ImageView pinnedView;
|
||||
private boolean onlyShowSendingStatus;
|
||||
private TextView audioDuration;
|
||||
private LottieAnimationView revealDot;
|
||||
@@ -98,6 +99,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
timerView = findViewById(R.id.footer_expiration_timer);
|
||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
||||
pinnedView = findViewById(R.id.footer_pinned);
|
||||
audioDuration = findViewById(R.id.footer_audio_duration);
|
||||
revealDot = findViewById(R.id.footer_revealed_dot);
|
||||
playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle);
|
||||
@@ -143,6 +145,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
presentInsecureIndicator(messageRecord);
|
||||
presentDeliveryStatus(messageRecord);
|
||||
presentAudioDuration(messageRecord);
|
||||
presentPinnedIcon(messageRecord);
|
||||
}
|
||||
|
||||
public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
|
||||
@@ -174,6 +177,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
insecureIndicatorView.setColorFilter(color);
|
||||
deliveryStatusView.setTint(color);
|
||||
pinnedView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
public void setRevealDotColor(int color) {
|
||||
@@ -428,6 +432,14 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void presentPinnedIcon(@NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.getPinnedUntil() > 0) {
|
||||
pinnedView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
pinnedView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void showAudioDurationViews() {
|
||||
audioDuration.setVisibility(View.VISIBLE);
|
||||
revealDot.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -472,7 +472,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
}
|
||||
|
||||
private void ellipsizeEmojiTextForMaxLines() {
|
||||
public void ellipsizeEmojiTextForMaxLines() {
|
||||
Runnable ellipsize = () -> {
|
||||
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
|
||||
if (maxLines <= 0 && maxLength < 0) {
|
||||
@@ -611,6 +611,13 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
}
|
||||
|
||||
public void enableRenderSpoilers() {
|
||||
if (spoilerRendererDelegate == null) {
|
||||
renderSpoilers = true;
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to some peculiarities in how TextView deals with touch events, it's really easy to accidentally trigger
|
||||
* a click (say, when you try to scroll but you're at the bottom of a view.) Because of this, we handle these
|
||||
|
||||
@@ -1921,7 +1921,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
|
||||
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
|
||||
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE;
|
||||
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE || messageRecord.getPinnedUntil() > 0;
|
||||
}
|
||||
|
||||
private boolean forceGroupHeader(@NonNull MessageRecord messageRecord) {
|
||||
|
||||
+11
-1
@@ -723,6 +723,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL)));
|
||||
}
|
||||
|
||||
if (menuState.shouldShowPinMessage()) {
|
||||
items.add(new ActionItem(R.drawable.symbol_pin_24, getResources().getString(R.string.conversation_selection__menu_pin_message), () -> handleActionItemClicked(Action.PIN_MESSAGE)));
|
||||
}
|
||||
|
||||
if (menuState.showShowUnpinMessage()) {
|
||||
items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getString(R.string.conversation_selection__menu_unpin_message), () -> handleActionItemClicked(Action.UNPIN_MESSAGE)));
|
||||
}
|
||||
|
||||
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
|
||||
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
|
||||
|
||||
@@ -908,6 +916,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
PAYMENT_DETAILS,
|
||||
VIEW_INFO,
|
||||
DELETE,
|
||||
END_POLL
|
||||
END_POLL,
|
||||
PIN_MESSAGE,
|
||||
UNPIN_MESSAGE
|
||||
}
|
||||
}
|
||||
@@ -685,6 +685,17 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
passthroughClickListener.onClick(v);
|
||||
}
|
||||
});
|
||||
} else if (MessageRecordUtil.hasPinnedMessageUpdate(conversationMessage.getMessageRecord())) {
|
||||
actionButton.setText(R.string.PinnedMessage__go_to_message);
|
||||
actionButton.setVisibility(VISIBLE);
|
||||
actionButton.setOnClickListener(v -> {
|
||||
// TODO(michelle): Handle when a message gets deleted
|
||||
if (batchSelected.isEmpty() && eventListener != null && MessageRecordUtil.hasPinnedMessageUpdate(conversationMessage.getMessageRecord())) {
|
||||
eventListener.onViewPinnedMessage(conversationMessage.getMessageRecord().getMessageExtras().pinnedMessage.pinnedMessageId);
|
||||
} else {
|
||||
passthroughClickListener.onClick(v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionButton.setVisibility(GONE);
|
||||
actionButton.setOnClickListener(null);
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -27,6 +28,8 @@ public final class MenuState {
|
||||
private final boolean paymentDetails;
|
||||
private final boolean edit;
|
||||
private final boolean pollTerminate;
|
||||
private final boolean pinMessage;
|
||||
private final boolean unpinMessage;
|
||||
|
||||
private MenuState(@NonNull Builder builder) {
|
||||
forward = builder.forward;
|
||||
@@ -40,6 +43,8 @@ public final class MenuState {
|
||||
paymentDetails = builder.paymentDetails;
|
||||
edit = builder.edit;
|
||||
pollTerminate = builder.pollTerminate;
|
||||
pinMessage = builder.pinMessage;
|
||||
unpinMessage = builder.unpinMessage;
|
||||
}
|
||||
|
||||
public boolean shouldShowForwardAction() {
|
||||
@@ -86,6 +91,14 @@ public final class MenuState {
|
||||
return pollTerminate;
|
||||
}
|
||||
|
||||
public boolean shouldShowPinMessage() {
|
||||
return pinMessage;
|
||||
}
|
||||
|
||||
public boolean showShowUnpinMessage() {
|
||||
return unpinMessage;
|
||||
}
|
||||
|
||||
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
|
||||
@NonNull Set<MultiselectPart> selectedParts,
|
||||
boolean shouldShowMessageRequest,
|
||||
@@ -105,6 +118,8 @@ public final class MenuState {
|
||||
boolean hasPayment = false;
|
||||
boolean hasPoll = false;
|
||||
boolean hasPollTerminate = false;
|
||||
boolean canPinMessage = false;
|
||||
boolean canUnpinMessage = false;
|
||||
|
||||
for (MultiselectPart part : selectedParts) {
|
||||
MessageRecord messageRecord = part.getMessageRecord();
|
||||
@@ -154,6 +169,14 @@ public final class MenuState {
|
||||
if (MessageRecordUtil.hasPoll(messageRecord) && !MessageRecordUtil.getPoll(messageRecord).getHasEnded() && messageRecord.isOutgoing()) {
|
||||
hasPollTerminate = true;
|
||||
}
|
||||
|
||||
if (RemoteConfig.sendPinnedMessages() && !messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes()) { // TODO(michelle): Also check against group permissions
|
||||
canPinMessage = true;
|
||||
}
|
||||
|
||||
if (RemoteConfig.sendPinnedMessages() && messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes()) { // TODO(michelle): Also check against group permissions
|
||||
canUnpinMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
boolean shouldShowForwardAction = !actionMessage &&
|
||||
@@ -178,7 +201,9 @@ public final class MenuState {
|
||||
.shouldShowSaveAttachmentAction(false)
|
||||
.shouldShowResendAction(false)
|
||||
.shouldShowEdit(false)
|
||||
.shouldShowPollTerminate(false);
|
||||
.shouldShowPollTerminate(false)
|
||||
.shouldShowPinMessage(false)
|
||||
.shouldShowUnpinMessage(false);
|
||||
} else {
|
||||
MultiselectPart multiSelectRecord = selectedParts.iterator().next();
|
||||
|
||||
@@ -210,6 +235,8 @@ public final class MenuState {
|
||||
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
|
||||
.shouldShowPaymentDetails(hasPayment)
|
||||
.shouldShowPollTerminate(hasPollTerminate)
|
||||
.shouldShowPinMessage(canPinMessage)
|
||||
.shouldShowUnpinMessage(canUnpinMessage)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -255,6 +282,8 @@ public final class MenuState {
|
||||
private boolean paymentDetails;
|
||||
private boolean edit;
|
||||
private boolean pollTerminate;
|
||||
private boolean pinMessage;
|
||||
private boolean unpinMessage;
|
||||
|
||||
@NonNull Builder shouldShowForwardAction(boolean forward) {
|
||||
this.forward = forward;
|
||||
@@ -311,6 +340,16 @@ public final class MenuState {
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder shouldShowPinMessage(boolean pinMessage) {
|
||||
this.pinMessage = pinMessage;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder shouldShowUnpinMessage(boolean unpinMessage) {
|
||||
this.unpinMessage = unpinMessage;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
MenuState build() {
|
||||
return new MenuState(this);
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom sheet to show all pinned messages
|
||||
*/
|
||||
class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private lateinit var messageAdapter: ConversationAdapter
|
||||
private val viewModel: PinnedMessagesViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val threadId = requireArguments().getLong(KEY_THREAD_ID, -1)
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
|
||||
PinnedMessagesViewModel.Factory(AppDependencies.application, threadId, conversationRecipientId)
|
||||
}
|
||||
)
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.pinned_messages_bottom_sheet, container, false)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
return view
|
||||
}
|
||||
|
||||
@SuppressLint("WrongThread")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
val colorizer = Colorizer()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
|
||||
setCondensedMode(ConversationItemDisplayMode.Condensed(scheduleMessageMode = false))
|
||||
}
|
||||
|
||||
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.pinned_list).apply {
|
||||
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
|
||||
adapter = messageAdapter
|
||||
itemAnimator = null
|
||||
|
||||
doOnNextLayout {
|
||||
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
|
||||
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
|
||||
}
|
||||
}
|
||||
|
||||
val recyclerViewColorizer = RecyclerViewColorizer(list)
|
||||
|
||||
disposables += viewModel.getMessages().subscribe { messages ->
|
||||
if (messages.isEmpty()) {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
messageAdapter.submitList(messages) {
|
||||
if (!list.canScrollVertically(1)) {
|
||||
list.layoutManager?.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
|
||||
}
|
||||
|
||||
disposables += viewModel.getNameColorsMap().subscribe { map ->
|
||||
colorizer.onNameColorsChanged(map)
|
||||
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapterBridge.PAYLOAD_NAME_COLORS)
|
||||
}
|
||||
|
||||
initializeGiphyMp4(view.findViewById(R.id.video_container)!!, list)
|
||||
|
||||
// TODO(michelle): Hide if not allowed to unpin / Check with design about a confirmation dialog here
|
||||
val unpinAll = view.findViewById<TextView>(R.id.unpin_all)
|
||||
unpinAll.setOnClickListener {
|
||||
viewModel.unpinMessage()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler {
|
||||
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
|
||||
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
|
||||
requireContext(),
|
||||
viewLifecycleOwner.lifecycle,
|
||||
videoContainer,
|
||||
maxPlayback
|
||||
)
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
private fun getCallback(): ConversationBottomSheetCallback {
|
||||
return findListener<ConversationBottomSheetCallback>() ?: throw IllegalStateException("Parent must implement callback interface!")
|
||||
}
|
||||
|
||||
private fun getAdapterListener(): ConversationAdapter.ItemClickListener {
|
||||
return getCallback().getConversationAdapterListener()
|
||||
}
|
||||
|
||||
// TODO(michelle): Allow for more interactions from the pinned messages sheet
|
||||
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
|
||||
override fun onItemClick(item: MultiselectPart) {
|
||||
dismiss()
|
||||
getCallback().jumpToMessage(item.getMessageRecord())
|
||||
}
|
||||
|
||||
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
|
||||
dismiss()
|
||||
getCallback().jumpToMessage(item.getMessageRecord())
|
||||
}
|
||||
|
||||
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
|
||||
dismiss()
|
||||
getCallback().jumpToMessage(messageRecord)
|
||||
}
|
||||
|
||||
override fun onLinkPreviewClicked(linkPreview: LinkPreview) {
|
||||
dismiss()
|
||||
getAdapterListener().onLinkPreviewClicked(linkPreview)
|
||||
}
|
||||
|
||||
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) {
|
||||
dismiss()
|
||||
getAdapterListener().onQuotedIndicatorClicked(messageRecord)
|
||||
}
|
||||
|
||||
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) {
|
||||
dismiss()
|
||||
getCallback().jumpToMessage(multiselectPart.conversationMessage.messageRecord)
|
||||
}
|
||||
|
||||
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {
|
||||
dismiss()
|
||||
getAdapterListener().onGroupMemberClicked(recipientId, groupId)
|
||||
}
|
||||
|
||||
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) {
|
||||
dismiss()
|
||||
getAdapterListener().onMessageWithRecaptchaNeededClicked(messageRecord)
|
||||
}
|
||||
|
||||
override fun onSingleVoiceNotePlay(uri: Uri, messageId: Long, position: Double) {
|
||||
super.onSingleVoiceNotePlay(uri, messageId, position)
|
||||
}
|
||||
|
||||
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) {
|
||||
dismiss()
|
||||
getAdapterListener().onGroupMigrationLearnMoreClicked(membershipChange)
|
||||
}
|
||||
|
||||
override fun onChatSessionRefreshLearnMoreClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onChatSessionRefreshLearnMoreClicked()
|
||||
}
|
||||
|
||||
override fun onBadDecryptLearnMoreClicked(author: RecipientId) {
|
||||
dismiss()
|
||||
getAdapterListener().onBadDecryptLearnMoreClicked(author)
|
||||
}
|
||||
|
||||
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) {
|
||||
dismiss()
|
||||
getAdapterListener().onSafetyNumberLearnMoreClicked(recipient)
|
||||
}
|
||||
|
||||
override fun onJoinGroupCallClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onJoinGroupCallClicked()
|
||||
}
|
||||
|
||||
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) {
|
||||
dismiss()
|
||||
getAdapterListener().onInviteFriendsToGroupClicked(groupId)
|
||||
}
|
||||
|
||||
override fun onEnableCallNotificationsClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onEnableCallNotificationsClicked()
|
||||
}
|
||||
|
||||
override fun onCallToAction(action: String) {
|
||||
dismiss()
|
||||
getAdapterListener().onCallToAction(action)
|
||||
}
|
||||
|
||||
override fun onDonateClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onDonateClicked()
|
||||
}
|
||||
|
||||
override fun onRecipientNameClicked(target: RecipientId) {
|
||||
dismiss()
|
||||
getAdapterListener().onRecipientNameClicked(target)
|
||||
}
|
||||
|
||||
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) {
|
||||
dismiss()
|
||||
getAdapterListener().onViewGiftBadgeClicked(messageRecord)
|
||||
}
|
||||
|
||||
override fun onActivatePaymentsClicked() {
|
||||
dismiss()
|
||||
getAdapterListener().onActivatePaymentsClicked()
|
||||
}
|
||||
|
||||
override fun onSendPaymentClicked(recipientId: RecipientId) {
|
||||
dismiss()
|
||||
getAdapterListener().onSendPaymentClicked(recipientId)
|
||||
}
|
||||
|
||||
override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) {
|
||||
dismiss()
|
||||
getAdapterListener().onEditedIndicatorClicked(conversationMessage)
|
||||
}
|
||||
|
||||
override fun onShowSafetyTips(forGroup: Boolean) = Unit
|
||||
override fun onReportSpamLearnMoreClicked() = Unit
|
||||
override fun onMessageRequestAcceptOptionsClicked() = Unit
|
||||
override fun onItemDoubleClick(item: MultiselectPart) = Unit
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
|
||||
override fun onViewResultsClicked(pollId: Long) = Unit
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessagesBottomSheet::class.java)
|
||||
|
||||
private const val KEY_THREAD_ID = "thread_id"
|
||||
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) {
|
||||
val args = Bundle().apply {
|
||||
putLong(KEY_THREAD_ID, threadId)
|
||||
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
|
||||
}
|
||||
|
||||
val fragment = PinnedMessagesBottomSheet().apply {
|
||||
arguments = args
|
||||
}
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.app.Application
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
||||
/**
|
||||
* Repository when getting the pinned messages shown in the pinned message bottom sheet
|
||||
*/
|
||||
class PinnedMessagesRepository {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessagesRepository::class.java)
|
||||
}
|
||||
|
||||
fun getPinnedMessage(application: Application, threadId: Long): Observable<List<ConversationMessage>> {
|
||||
return Observable.create { emitter ->
|
||||
emitter.onNext(getPinnedMessages(application, threadId))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getPinnedMessageRecords(threadId: Long): List<MessageRecord> {
|
||||
return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = false)
|
||||
}
|
||||
|
||||
private fun getPinnedMessages(application: Application, threadId: Long): List<ConversationMessage> {
|
||||
var records: List<MessageRecord> = getPinnedMessageRecords(threadId)
|
||||
|
||||
val reactionHelper = ReactionHelper()
|
||||
val attachmentHelper = AttachmentHelper()
|
||||
val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(threadId))
|
||||
|
||||
reactionHelper.addAll(records)
|
||||
attachmentHelper.addAll(records)
|
||||
|
||||
reactionHelper.fetchReactions()
|
||||
attachmentHelper.fetchAttachments()
|
||||
|
||||
records = reactionHelper.buildUpdatedModels(records)
|
||||
records = attachmentHelper.buildUpdatedModels(AppDependencies.application, records)
|
||||
|
||||
return records.map { ConversationMessageFactory.createWithUnresolvedData(application, it, threadRecipient) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.UnpinMessageJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* View model for the pinned messages bottom sheet
|
||||
*/
|
||||
class PinnedMessagesViewModel(
|
||||
application: Application,
|
||||
private val threadId: Long,
|
||||
private val conversationRecipientId: RecipientId
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessagesViewModel::class.java)
|
||||
}
|
||||
|
||||
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
|
||||
private val repository = PinnedMessagesRepository()
|
||||
|
||||
fun getMessages(): Observable<List<ConversationMessage>> {
|
||||
return repository
|
||||
.getPinnedMessage(getApplication(), threadId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
|
||||
return Observable.just(conversationRecipientId)
|
||||
.map { conversationRecipientId ->
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
if (conversationRecipient.groupId.isPresent) {
|
||||
groupAuthorNameColorHelper.getColorMap(conversationRecipient.groupId.get())
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun unpinMessage() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
repository.getPinnedMessageRecords(threadId).map {
|
||||
val unpinJob = UnpinMessageJob.create(messageId = it.id)
|
||||
if (unpinJob != null) {
|
||||
AppDependencies.jobManager.add(unpinJob)
|
||||
} else {
|
||||
Log.w(TAG, "Unable to create unpin job for message ${it.id}, ignoring.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val application: Application, private val threadId: Long, private val conversationRecipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(PinnedMessagesViewModel(application, threadId, conversationRecipientId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -21,11 +21,14 @@ import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.BannerManager
|
||||
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView
|
||||
import org.thoughtcrime.securesms.compose.SignalTheme
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.views.Stub
|
||||
@@ -50,6 +53,7 @@ class ConversationBannerView @JvmOverloads constructor(
|
||||
private val bannerStub: Stub<ComposeView> by lazy { ViewUtil.findStubById(this, R.id.banner_stub) }
|
||||
private val reviewBannerStub: Stub<ReviewBannerView> by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) }
|
||||
private val voiceNotePlayerStub: Stub<View> by lazy { ViewUtil.findStubById(this, R.id.voice_note_player_stub) }
|
||||
private val pinnedMessageStub: Stub<ComposeView> by lazy { ViewUtil.findStubById(this, R.id.pinned_message_stub) }
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
@@ -129,6 +133,29 @@ class ConversationBannerView @JvmOverloads constructor(
|
||||
hide(voiceNotePlayerStub)
|
||||
}
|
||||
|
||||
fun showPinnedMessageStub(messages: List<ConversationMessage>) {
|
||||
show(
|
||||
stub = pinnedMessageStub
|
||||
) {
|
||||
this.apply {
|
||||
setContent {
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) {
|
||||
PinnedMessagesBanner(
|
||||
messages = messages,
|
||||
onUnpinMessage = { messageId -> listener?.onUnpinMessage(messageId) },
|
||||
onGoToMessage = { messageId -> listener?.onGoToMessage(messageId) },
|
||||
onViewAllMessages = { listener?.onViewAllMessages() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hidePinnedMessageStub() {
|
||||
hide(pinnedMessageStub)
|
||||
}
|
||||
|
||||
private fun <V : View> show(stub: Stub<V>, bind: V.() -> Unit = {}) {
|
||||
TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP))
|
||||
stub.get().bind()
|
||||
@@ -177,5 +204,8 @@ class ConversationBannerView @JvmOverloads constructor(
|
||||
fun onRequestReviewIndividual(recipientId: RecipientId)
|
||||
fun onReviewGroupMembers(groupId: GroupId.V2)
|
||||
fun onDismissReview()
|
||||
fun onUnpinMessage(messageId: Long)
|
||||
fun onGoToMessage(messageId: Long)
|
||||
fun onViewAllMessages()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ import org.thoughtcrime.securesms.conversation.MarkReadHelper
|
||||
import org.thoughtcrime.securesms.conversation.MenuState
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler.getStyling
|
||||
import org.thoughtcrime.securesms.conversation.PinnedMessagesBottomSheet
|
||||
import org.thoughtcrime.securesms.conversation.ReenableScheduledMessagesDialogFragment
|
||||
import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu
|
||||
import org.thoughtcrime.securesms.conversation.ScheduleMessageDialogCallback
|
||||
@@ -377,6 +378,7 @@ import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.ExecutionException
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
@@ -1179,6 +1181,16 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel
|
||||
.pinnedMessages
|
||||
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
|
||||
.flowOn(Dispatchers.Main)
|
||||
.collect {
|
||||
presentPinnedMessage(it)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
val recipient = viewModel.recipientSnapshot
|
||||
@@ -1322,6 +1334,14 @@ class ConversationFragment :
|
||||
.addTo(disposables)
|
||||
}
|
||||
|
||||
private fun presentPinnedMessage(pinnedMessages: List<ConversationMessage>) {
|
||||
if (pinnedMessages.isNotEmpty()) {
|
||||
binding.conversationBanner.showPinnedMessageStub(pinnedMessages)
|
||||
} else {
|
||||
binding.conversationBanner.hidePinnedMessageStub()
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentTypingIndicator() {
|
||||
typingIndicatorAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
@@ -1656,6 +1676,55 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinMessage(conversationMessage: ConversationMessage) {
|
||||
if (viewModel.pinnedMessages.value.size >= RemoteConfig.pinLimit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getString(R.string.ConversationFragment__replace_title))
|
||||
.setMessage(resources.getString(R.string.ConversationFragment__replace_body))
|
||||
.setPositiveButton(R.string.ConversationFragment__replace) { _, _ ->
|
||||
showPinForDialog(conversationMessage)
|
||||
}
|
||||
.setNegativeButton(R.string.ConversationFragment__cancel) { dialog, _ -> dialog.dismiss() }
|
||||
.show()
|
||||
} else {
|
||||
showPinForDialog(conversationMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPinForDialog(conversationMessage: ConversationMessage) {
|
||||
var selection = 1
|
||||
val labels = resources.getStringArray(R.array.ConversationFragment__pinned_for_labels)
|
||||
val values = resources.getIntArray(R.array.ConversationFragment__pinned_for_values)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getString(R.string.ConversationFragment__keep_pinned))
|
||||
.setSingleChoiceItems(labels, selection) { dialog, which ->
|
||||
selection = which
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
if (conversationMessage.messageRecord.expiresIn > 0 && SignalStore.uiHints.shouldDisplayPinnedSheet()) {
|
||||
PinDisappearingMessageBottomSheet.show(childFragmentManager)
|
||||
SignalStore.uiHints.incrementSeenPinnedSheetCount()
|
||||
}
|
||||
disposables += viewModel
|
||||
.pinMessage(
|
||||
messageRecord = conversationMessage.messageRecord,
|
||||
duration = if (values[selection] == -1) kotlin.time.Duration.INFINITE else values[selection].days,
|
||||
threadRecipient = conversationMessage.threadRecipient
|
||||
)
|
||||
.subscribe()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun handleUnpinMessage(messageId: Long) {
|
||||
viewModel.unpinMessage(messageId)
|
||||
}
|
||||
|
||||
private fun handleVideoCall() {
|
||||
val recipient = viewModel.recipientSnapshot ?: return
|
||||
if (!recipient.isGroup) {
|
||||
@@ -2712,6 +2781,15 @@ class ConversationFragment :
|
||||
progressDialog = null
|
||||
}
|
||||
|
||||
private fun viewPinnedMessage(messageId: Long) {
|
||||
disposables += viewModel
|
||||
.moveToMessage(messageId)
|
||||
.subscribeBy(
|
||||
onSuccess = { moveToPosition(it) },
|
||||
onError = { Toast.makeText(requireContext(), R.string.PinnedMessage__not_found, Toast.LENGTH_LONG).show() }
|
||||
)
|
||||
}
|
||||
|
||||
private inner class SwipeAvailabilityProvider : ConversationItemSwipeCallback.SwipeAvailabilityProvider {
|
||||
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
|
||||
val recipient = viewModel.recipientSnapshot ?: return false
|
||||
@@ -3243,6 +3321,10 @@ class ConversationFragment :
|
||||
viewModel.toggleVote(poll, pollOption, isChecked)
|
||||
}
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) {
|
||||
viewPinnedMessage(messageId)
|
||||
}
|
||||
|
||||
override fun onJoinGroupCallClicked() {
|
||||
val activity = activity ?: return
|
||||
val recipient = viewModel.recipientSnapshot ?: return
|
||||
@@ -3936,6 +4018,8 @@ class ConversationFragment :
|
||||
ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage)
|
||||
ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
|
||||
ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id)
|
||||
ConversationReactionOverlay.Action.PIN_MESSAGE -> handlePinMessage(conversationMessage)
|
||||
ConversationReactionOverlay.Action.UNPIN_MESSAGE -> handleUnpinMessage(conversationMessage.messageRecord.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4136,6 +4220,22 @@ class ConversationFragment :
|
||||
override fun onDismissReview() {
|
||||
viewModel.onDismissReview()
|
||||
}
|
||||
|
||||
override fun onUnpinMessage(messageId: Long) {
|
||||
handleUnpinMessage(messageId)
|
||||
}
|
||||
|
||||
override fun onGoToMessage(messageId: Long) {
|
||||
viewPinnedMessage(messageId)
|
||||
}
|
||||
|
||||
override fun onViewAllMessages() {
|
||||
PinnedMessagesBottomSheet.show(
|
||||
childFragmentManager,
|
||||
args.threadId,
|
||||
viewModel.recipientSnapshot?.id!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
+33
@@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies.databaseObserver
|
||||
@@ -300,6 +301,38 @@ class ConversationRepository(
|
||||
) { System.currentTimeMillis() - sentTime > POLL_TERMINATE_TIMEOUT.inWholeMilliseconds }
|
||||
}
|
||||
|
||||
fun getPinnedMessages(threadId: Long): List<MmsMessageRecord> {
|
||||
return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = true)
|
||||
}
|
||||
|
||||
fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable {
|
||||
return Completable.create { emitter ->
|
||||
val message = OutgoingMessage.pinMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
messageExtras = MessageExtras(
|
||||
pinnedMessage = PinnedMessage(
|
||||
pinnedMessageId = messageRecord.id,
|
||||
targetAuthorAci = messageRecord.fromRecipient.requireAci().toByteString(),
|
||||
targetTimestamp = messageRecord.dateSent,
|
||||
pinDurationInSeconds = if (duration.isInfinite()) MessageTable.PIN_FOREVER else duration.inWholeSeconds
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Log.i(TAG, "Sending pin create to ${message.threadRecipient.id}, thread: ${messageRecord.threadId}")
|
||||
|
||||
MessageSender.send(
|
||||
AppDependencies.application,
|
||||
message,
|
||||
messageRecord.threadId,
|
||||
MessageSender.SendType.SIGNAL,
|
||||
null
|
||||
) { emitter.onComplete() }
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun applyUniversalExpireTimerIfNecessary(context: Context, recipient: Recipient, outgoingMessage: OutgoingMessage, threadId: Long): OutgoingMessage {
|
||||
if (!outgoingMessage.isExpirationUpdate && outgoingMessage.expiresIn == 0L) {
|
||||
val expireTimerVersion = RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId)
|
||||
|
||||
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.PollVoteJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.UnpinMessageJob
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
@@ -207,6 +208,9 @@ class ConversationViewModel(
|
||||
private val internalBackPressedState = MutableStateFlow(BackPressedState())
|
||||
val backPressedState: StateFlow<BackPressedState> = internalBackPressedState
|
||||
|
||||
private val internalPinnedMessages = MutableStateFlow<List<ConversationMessage>>(emptyList())
|
||||
val pinnedMessages: StateFlow<List<ConversationMessage>> = internalPinnedMessages
|
||||
|
||||
init {
|
||||
disposables += recipient
|
||||
.subscribeBy {
|
||||
@@ -237,6 +241,8 @@ class ConversationViewModel(
|
||||
_conversationThreadState.onNext(it)
|
||||
})
|
||||
|
||||
getPinnedMessages()
|
||||
|
||||
disposables += conversationThreadState.flatMapObservable { threadState ->
|
||||
Observable.create<Unit> { emitter ->
|
||||
val controller = threadState.items.controller
|
||||
@@ -248,6 +254,7 @@ class ConversationViewModel(
|
||||
}
|
||||
val conversationObserver = DatabaseObserver.Observer {
|
||||
controller.onDataInvalidated()
|
||||
getPinnedMessages()
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.registerMessageUpdateObserver(messageUpdateObserver)
|
||||
@@ -339,6 +346,32 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPinnedMessages() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
internalPinnedMessages.value = repository.getPinnedMessages(threadId).map {
|
||||
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(AppDependencies.application, it, threadRecipient!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable {
|
||||
return repository
|
||||
.pinMessage(messageRecord, duration, threadRecipient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun unpinMessage(messageId: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val unpinJob = UnpinMessageJob.create(messageId = messageId)
|
||||
if (unpinJob != null) {
|
||||
AppDependencies.jobManager.add(unpinJob)
|
||||
} else {
|
||||
Log.w(TAG, "Unable to create unpin job, ignoring.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateThreadHeader() {
|
||||
pagingController.onDataItemChanged(ConversationElementKey.threadHeader)
|
||||
}
|
||||
|
||||
+97
@@ -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()
|
||||
}
|
||||
}
|
||||
+221
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -43,7 +43,8 @@ fun V2ConversationItemMediaIncomingBinding.bridge(): V2ConversationItemMediaBind
|
||||
footerBackground = conversationItemFooterBackground,
|
||||
alert = null,
|
||||
footerSpace = null,
|
||||
isIncoming = true
|
||||
isIncoming = true,
|
||||
footerPinned = conversationItemFooterPinned
|
||||
)
|
||||
|
||||
return V2ConversationItemMediaBindingBridge(
|
||||
@@ -73,7 +74,8 @@ fun V2ConversationItemMediaOutgoingBinding.bridge(): V2ConversationItemMediaBind
|
||||
footerBackground = conversationItemFooterBackground,
|
||||
alert = conversationItemAlert,
|
||||
footerSpace = footerEndPad,
|
||||
isIncoming = false
|
||||
isIncoming = false,
|
||||
footerPinned = conversationItemFooterPinned
|
||||
)
|
||||
|
||||
return V2ConversationItemMediaBindingBridge(
|
||||
|
||||
+7
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@ class V2FooterPositionDelegate private constructor(
|
||||
binding.footerDate,
|
||||
binding.deliveryStatus,
|
||||
binding.footerExpiry,
|
||||
binding.footerSpace
|
||||
binding.footerSpace,
|
||||
binding.footerPinned
|
||||
),
|
||||
binding.bodyWrapper,
|
||||
binding.body,
|
||||
|
||||
@@ -111,6 +111,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
@@ -140,6 +141,7 @@ import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.isStory
|
||||
@@ -155,6 +157,7 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper), MessageTypes, RecipientIdDatabaseReference, ThreadIdDatabaseReference {
|
||||
|
||||
@@ -217,6 +220,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val MESSAGE_EXTRAS = "message_extras"
|
||||
const val VOTES_UNREAD = "votes_unread"
|
||||
const val VOTES_LAST_SEEN = "votes_last_seen"
|
||||
const val PINNED_UNTIL = "pinned_until"
|
||||
const val PINNING_MESSAGE_ID = "pinning_message_id"
|
||||
const val PINNED_AT = "pinned_at"
|
||||
|
||||
const val QUOTE_NOT_PRESENT_ID = 0L
|
||||
const val QUOTE_TARGET_MISSING_ID = -1L
|
||||
@@ -224,6 +230,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val ADDRESSABLE_MESSAGE_LIMIT = 5
|
||||
const val PARENT_STORY_MISSING_ID = -1L
|
||||
|
||||
const val PIN_FOREVER = Long.MAX_VALUE
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -281,7 +289,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$MESSAGE_EXTRAS BLOB DEFAULT NULL,
|
||||
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL,
|
||||
$VOTES_UNREAD INTEGER DEFAULT 0,
|
||||
$VOTES_LAST_SEEN INTEGER DEFAULT 0
|
||||
$VOTES_LAST_SEEN INTEGER DEFAULT 0,
|
||||
$PINNED_UNTIL INTEGER DEFAULT 0,
|
||||
$PINNING_MESSAGE_ID INTEGER DEFAULT 0,
|
||||
$PINNED_AT INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -312,7 +323,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL",
|
||||
// This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0",
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)"
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)",
|
||||
"CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)",
|
||||
"CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)"
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION_BASE = arrayOf(
|
||||
@@ -367,7 +380,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
REVISION_NUMBER,
|
||||
MESSAGE_EXTRAS,
|
||||
VOTES_UNREAD,
|
||||
VOTES_LAST_SEEN
|
||||
VOTES_LAST_SEEN,
|
||||
PINNED_UNTIL
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
|
||||
@@ -2027,7 +2041,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0): Cursor {
|
||||
/**
|
||||
* Note: [reverse] and [orderBy] are mutually exclusive. If you want the order to be reversed, explicitly use 'ASC' or 'DESC'
|
||||
*/
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0, orderBy: String = ""): Cursor {
|
||||
val database = databaseHelper.signalReadableDatabase
|
||||
var rawQueryString = """
|
||||
SELECT
|
||||
@@ -2040,7 +2057,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$TABLE_NAME.$ID
|
||||
""".toSingleLine()
|
||||
|
||||
if (reverse) {
|
||||
if (orderBy.isNotEmpty()) {
|
||||
rawQueryString += " ORDER BY $orderBy"
|
||||
} else if (reverse) {
|
||||
rawQueryString += " ORDER BY $TABLE_NAME.$ID DESC"
|
||||
}
|
||||
|
||||
@@ -2068,6 +2087,27 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun getPinnedMessages(threadId: Long, orderByPinned: Boolean): List<MmsMessageRecord> {
|
||||
val cursor = rawQueryWithAttachments(
|
||||
where = "$THREAD_ID = ? AND $PINNED_UNTIL > 0",
|
||||
arguments = buildArgs(threadId),
|
||||
reverse = true,
|
||||
orderBy = if (orderByPinned) "$PINNED_AT ASC" else ""
|
||||
)
|
||||
|
||||
return mmsReaderFor(cursor).use { reader ->
|
||||
reader.mapNotNull {
|
||||
if (!it.isMms) {
|
||||
null
|
||||
} else if (it.isPaymentNotification) {
|
||||
SignalDatabase.payments.updateMessageWithPayment(it) as MmsMessageRecord
|
||||
} else {
|
||||
it as MmsMessageRecord
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecentPendingMessages(): MmsReader {
|
||||
val now = System.currentTimeMillis()
|
||||
val oneDayAgo = now.milliseconds - 1.days
|
||||
@@ -2695,6 +2735,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras
|
||||
)
|
||||
} else if (MessageTypes.isPinnedMessageUpdate(outboxType) && messageExtras != null) {
|
||||
OutgoingMessage.pinMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras
|
||||
)
|
||||
} else {
|
||||
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(body))
|
||||
@@ -2850,7 +2897,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
updateThread = updateThread,
|
||||
unarchive = true,
|
||||
poll = retrieved.poll,
|
||||
pollTerminate = retrieved.messageExtras?.pollTerminate
|
||||
pollTerminate = retrieved.messageExtras?.pollTerminate,
|
||||
pinnedMessage = retrieved.messageExtras?.pinnedMessage
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3181,6 +3229,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
if (message.messageExtras?.pinnedMessage != null) {
|
||||
if (hasSpecialType) {
|
||||
throw MmsException("Cannot insert message with multiple special types.")
|
||||
}
|
||||
type = type or MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
val earlyDeliveryReceipts: Map<RecipientId, Receipt> = earlyDeliveryReceiptCache.remove(message.sentTimeMillis)
|
||||
|
||||
if (earlyDeliveryReceipts.isNotEmpty()) {
|
||||
@@ -3296,7 +3352,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
updateThread = false,
|
||||
unarchive = false,
|
||||
poll = message.poll,
|
||||
pollTerminate = message.messageExtras?.pollTerminate
|
||||
pollTerminate = message.messageExtras?.pollTerminate,
|
||||
pinnedMessage = message.messageExtras?.pinnedMessage
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3405,7 +3462,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
updateThread: Boolean,
|
||||
unarchive: Boolean,
|
||||
poll: Poll? = null,
|
||||
pollTerminate: PollTerminate? = null
|
||||
pollTerminate: PollTerminate? = null,
|
||||
pinnedMessage: PinnedMessage?
|
||||
): kotlin.Pair<Long, Map<Attachment, AttachmentId>?> {
|
||||
val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf }
|
||||
val allAttachments: MutableList<Attachment> = mutableListOf()
|
||||
@@ -3471,6 +3529,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
if (pinnedMessage != null) {
|
||||
val pinnedUntil = if (pinnedMessage.pinDurationInSeconds == PIN_FOREVER) {
|
||||
PIN_FOREVER
|
||||
} else {
|
||||
System.currentTimeMillis() + pinnedMessage.pinDurationInSeconds.seconds.inWholeMilliseconds
|
||||
}
|
||||
val rows = db
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
PINNED_UNTIL to pinnedUntil,
|
||||
PINNING_MESSAGE_ID to messageId,
|
||||
PINNED_AT to System.currentTimeMillis()
|
||||
)
|
||||
.where("$ID = ?", pinnedMessage.pinnedMessageId)
|
||||
.run()
|
||||
|
||||
if (rows <= 0) {
|
||||
Log.w(TAG, "Failed to pin a message.")
|
||||
} else {
|
||||
enforcePinSizeLimit(threadId, RemoteConfig.pinLimit)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
messageId to insertedAttachments
|
||||
}
|
||||
|
||||
@@ -3487,6 +3570,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
threads.update(threadId, unarchive)
|
||||
}
|
||||
|
||||
if (pinnedMessage != null && pinnedMessage.pinDurationInSeconds != PIN_FOREVER) {
|
||||
AppDependencies.pinnedMessageManager.scheduleIfNecessary()
|
||||
}
|
||||
|
||||
return messageId to insertedAttachments
|
||||
}
|
||||
|
||||
@@ -3544,6 +3631,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
return rowsDeleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins the oldest pins if the thread exceeds the [limit]
|
||||
*/
|
||||
private fun enforcePinSizeLimit(threadId: Long, limit: Int) {
|
||||
val pinnedList = readableDatabase
|
||||
.select(PINNED_AT)
|
||||
.from(TABLE_NAME)
|
||||
.where("$THREAD_ID = ? AND $PINNED_UNTIL > 0", threadId)
|
||||
.orderBy("$PINNED_AT DESC")
|
||||
.run()
|
||||
.readToList { cursor -> cursor.requireLong(PINNED_AT) }
|
||||
|
||||
if (pinnedList.size > limit) {
|
||||
val oldestPin = pinnedList[limit]
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
PINNED_UNTIL to 0,
|
||||
PINNED_AT to 0
|
||||
)
|
||||
.where("$PINNED_AT > 0 AND $PINNED_AT <= ?", oldestPin)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: Long): Boolean {
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
return deleteMessage(messageId, threadId)
|
||||
@@ -5084,6 +5196,33 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun getOldestExpiringPinnedMessageTimestamp(): MessageRecord? {
|
||||
val cursor = readableDatabase
|
||||
.select(*MMS_PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$PINNED_UNTIL > 0 AND $PINNED_UNTIL != ?", PIN_FOREVER)
|
||||
.orderBy("$PINNED_UNTIL ASC, $ID ASC")
|
||||
.limit(1)
|
||||
.run()
|
||||
|
||||
return mmsReaderFor(cursor).use { reader ->
|
||||
reader.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getPinnedMessagesBefore(time: Long): List<MessageRecord> {
|
||||
val cursor = readableDatabase
|
||||
.select(*MMS_PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$PINNED_UNTIL > 0 AND $PINNED_UNTIL <= ?", time)
|
||||
.orderBy("$PINNED_UNTIL ASC, $ID ASC")
|
||||
.run()
|
||||
|
||||
return mmsReaderFor(cursor).use { reader ->
|
||||
reader.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessagesForNotificationState(stickyThreads: Collection<StickyThread>): Cursor {
|
||||
val stickyQuery = StringBuilder()
|
||||
|
||||
@@ -5306,6 +5445,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun unpinMessage(messageId: Long, threadId: Long) {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(PINNED_UNTIL to 0)
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected fun <D : Document<I>?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class<D>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
@@ -5463,6 +5611,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.PINNED_MESSAGE -> MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.GROUP_UPDATE -> {
|
||||
val isOnlyGroupLeave = this.groupContext?.let { GroupV2UpdateMessageUtil.isJustAGroupLeave(it) } ?: false
|
||||
|
||||
@@ -6005,6 +6154,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
val originalMessageId: MessageId? = cursor.requireLong(ORIGINAL_MESSAGE_ID).let { if (it == 0L) null else MessageId(it) }
|
||||
val editCount = cursor.requireInt(REVISION_NUMBER)
|
||||
val isRead = cursor.requireBoolean(READ)
|
||||
val pinnedUntil = cursor.requireLong(PINNED_UNTIL)
|
||||
val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS)
|
||||
val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
|
||||
@@ -6099,6 +6249,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
originalMessageId,
|
||||
editCount,
|
||||
isRead,
|
||||
pinnedUntil,
|
||||
messageExtras
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,5 +48,8 @@ enum class MessageType {
|
||||
END_SESSION,
|
||||
|
||||
/** A poll has ended **/
|
||||
POLL_TERMINATE
|
||||
POLL_TERMINATE,
|
||||
|
||||
/** A message has been pinned **/
|
||||
PINNED_MESSAGE
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ public interface MessageTypes {
|
||||
long SPECIAL_TYPE_BLOCKED = 0xA00000000L;
|
||||
long SPECIAL_TYPE_UNBLOCKED = 0xB00000000L;
|
||||
long SPECIAL_TYPE_POLL_TERMINATE = 0xC00000000L;
|
||||
long SPECIAL_TYPE_PINNED_MESSAGE = 0xD00000000L;
|
||||
|
||||
long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
|
||||
|
||||
@@ -170,6 +171,10 @@ public interface MessageTypes {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_POLL_TERMINATE;
|
||||
}
|
||||
|
||||
static boolean isPinnedMessageUpdate(long type) {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PINNED_MESSAGE;
|
||||
}
|
||||
|
||||
static boolean isDraftMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ public final class ThreadBodyUtil {
|
||||
} else if (MessageRecordUtil.hasPollTerminate(record)) {
|
||||
return record.getFromRecipient().isSelf() ? new ThreadBody(context.getString(R.string.Poll__you_poll_end, record.getMessageExtras().pollTerminate.question))
|
||||
: new ThreadBody(context.getString(R.string.Poll__poll_end, record.getFromRecipient().getDisplayName(context), record.getMessageExtras().pollTerminate.question));
|
||||
} else if (MessageRecordUtil.hasPinnedMessageUpdate(record)) {
|
||||
return record.getFromRecipient().isSelf() ? new ThreadBody(context.getString(R.string.PinnedMessage__you_pinned_a_message))
|
||||
: new ThreadBody(context.getString(R.string.PinnedMessage__s_pinned_a_message, record.getFromRecipient().getDisplayName(context)));
|
||||
}
|
||||
|
||||
boolean hasImage = false;
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Adds the columns and indexes necessary for pinned messages
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V297_AddPinnedMessageColumns : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN pinned_until INTEGER DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN pinning_message_id INTEGER DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN pinned_at INTEGER DEFAULT 0")
|
||||
|
||||
db.execSQL("CREATE INDEX message_pinned_until_index ON message (pinned_until)")
|
||||
db.execSQL("CREATE INDEX message_pinned_at_index ON message (pinned_at)")
|
||||
}
|
||||
}
|
||||
@@ -264,4 +264,8 @@ public abstract class DisplayRecord {
|
||||
public boolean isPollTerminate() {
|
||||
return MessageTypes.isPollTerminate(type);
|
||||
}
|
||||
|
||||
public boolean isPinnedMessageUpdate() {
|
||||
return MessageTypes.isPinnedMessageUpdate(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class InMemoryMessageRecord extends MessageRecord {
|
||||
-1,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
null);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
private final long receiptTimestamp;
|
||||
private final MessageId originalMessageId;
|
||||
private final int revisionNumber;
|
||||
private final long pinnedUntil;
|
||||
private final MessageExtras messageExtras;
|
||||
|
||||
protected Boolean isJumboji = null;
|
||||
@@ -135,6 +136,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
long receiptTimestamp,
|
||||
@Nullable MessageId originalMessageId,
|
||||
int revisionNumber,
|
||||
long pinnedUntil,
|
||||
@Nullable MessageExtras messageExtras)
|
||||
{
|
||||
super(body, fromRecipient, toRecipient, dateSent, dateReceived,
|
||||
@@ -156,6 +158,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
this.receiptTimestamp = receiptTimestamp;
|
||||
this.originalMessageId = originalMessageId;
|
||||
this.revisionNumber = revisionNumber;
|
||||
this.pinnedUntil = pinnedUntil;
|
||||
this.messageExtras = messageExtras;
|
||||
}
|
||||
|
||||
@@ -297,6 +300,9 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
} else if (MessageRecordUtil.hasPollTerminate(this)) {
|
||||
return getFromRecipient().isSelf() ? staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_you_ended_the_poll, messageExtras.pollTerminate.question), Glyph.POLL)
|
||||
: staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_ended_the_poll, getFromRecipient().getDisplayName(context), messageExtras.pollTerminate.question), Glyph.POLL);
|
||||
} else if (MessageRecordUtil.hasPinnedMessageUpdate(this)) {
|
||||
return getFromRecipient().isSelf() ? staticUpdateDescriptionWithExpiration(context.getString(R.string.PinnedMessage__you_pinned_a_message), Glyph.PIN)
|
||||
: staticUpdateDescriptionWithExpiration(context.getString(R.string.PinnedMessage__s_pinned_a_message, getFromRecipient().getDisplayName(context)), Glyph.PIN);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -740,7 +746,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() ||
|
||||
isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() ||
|
||||
isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() ||
|
||||
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate();
|
||||
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate() || isPinnedMessageUpdate();
|
||||
}
|
||||
|
||||
public boolean isMediaPending() {
|
||||
@@ -775,6 +781,10 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return MessageTypes.isChatSessionRefresh(type);
|
||||
}
|
||||
|
||||
public long getPinnedUntil() {
|
||||
return pinnedUntil;
|
||||
}
|
||||
|
||||
public boolean isInMemoryMessageRecord() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -120,12 +120,13 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
@Nullable MessageId originalMessageId,
|
||||
int revisionNumber,
|
||||
boolean isRead,
|
||||
long pinnedUntil,
|
||||
@Nullable MessageExtras messageExtras)
|
||||
{
|
||||
super(id, body, fromRecipient, fromDeviceId, toRecipient,
|
||||
dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt,
|
||||
mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt,
|
||||
unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, messageExtras);
|
||||
unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, messageExtras);
|
||||
|
||||
this.slideDeck = slideDeck;
|
||||
this.quote = quote;
|
||||
@@ -338,7 +339,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withoutQuote() {
|
||||
@@ -346,7 +347,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withAttachments(@NonNull List<DatabaseAttachment> attachments) {
|
||||
@@ -368,7 +369,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) {
|
||||
@@ -376,7 +377,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +386,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) {
|
||||
@@ -393,7 +394,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras());
|
||||
}
|
||||
|
||||
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringStoriesManager
|
||||
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager
|
||||
import org.thoughtcrime.securesms.service.PinnedMessageManager
|
||||
import org.thoughtcrime.securesms.service.ScheduledMessageManager
|
||||
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager
|
||||
@@ -214,6 +215,11 @@ object AppDependencies {
|
||||
provider.provideScheduledMessageManager()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val pinnedMessageManager: PinnedMessageManager by lazy {
|
||||
provider.providePinnedMessageManager()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val androidCallAudioManager: AudioManagerCompat by lazy {
|
||||
provider.provideAndroidCallAudioManager()
|
||||
@@ -430,6 +436,7 @@ object AppDependencies {
|
||||
fun provideDeadlockDetector(): DeadlockDetector
|
||||
fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations
|
||||
fun provideScheduledMessageManager(): ScheduledMessageManager
|
||||
fun providePinnedMessageManager(): PinnedMessageManager
|
||||
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
|
||||
fun provideBillingApi(): BillingApi
|
||||
fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi
|
||||
|
||||
+6
@@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
|
||||
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
|
||||
import org.thoughtcrime.securesms.service.PinnedMessageManager;
|
||||
import org.thoughtcrime.securesms.service.ScheduledMessageManager;
|
||||
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
@@ -271,6 +272,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
return new ScheduledMessageManager(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PinnedMessageManager providePinnedMessageManager() {
|
||||
return new PinnedMessageManager(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) {
|
||||
Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT);
|
||||
|
||||
@@ -97,6 +97,7 @@ object SignalSymbols {
|
||||
CHEVRON_SQUARE_RIGHT('\uE02D'),
|
||||
CHEVRON_SQUARE_UP('\uE02E'),
|
||||
CHEVRON_SQUARE_DOWN('\uE02F'),
|
||||
CREDIT_CARD('\uE127'),
|
||||
DROPDOWN_DOWN('\uE07F'),
|
||||
DROPDOWN_UP('\uE080'),
|
||||
DROPDOWN_TRIANGLE_DOWN('\uE082'),
|
||||
@@ -163,6 +164,7 @@ object SignalSymbols {
|
||||
PHONE('\uE063'),
|
||||
PHONE_FILL('\uE064'),
|
||||
PHOTO('\uE065'),
|
||||
PIN('\uE12E'),
|
||||
PLAY('\uE067'),
|
||||
PLUS('\u002B'),
|
||||
PLUS_CIRCLE('\u2295'),
|
||||
|
||||
@@ -286,6 +286,7 @@ public class IndividualSendJob extends PushSendJob {
|
||||
SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message);
|
||||
SignalServiceDataMessage.Payment payment = getPayment(message);
|
||||
List<BodyRange> bodyRanges = getBodyRanges(message);
|
||||
SignalServiceDataMessage.PinnedMessage pinnedMessage = getPinnedMessage(message);
|
||||
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
|
||||
.withBody(message.getBody())
|
||||
.withAttachments(serviceAttachments)
|
||||
@@ -301,7 +302,8 @@ public class IndividualSendJob extends PushSendJob {
|
||||
.asExpirationUpdate(message.isExpirationUpdate())
|
||||
.asEndSessionMessage(message.isEndSession())
|
||||
.withPayment(payment)
|
||||
.withBodyRanges(bodyRanges);
|
||||
.withBodyRanges(bodyRanges)
|
||||
.withPinnedMessage(pinnedMessage);
|
||||
|
||||
if (message.getParentStoryId() != null) {
|
||||
try {
|
||||
|
||||
@@ -289,6 +289,7 @@ public final class JobManagerFactories {
|
||||
put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory());
|
||||
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
|
||||
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
||||
put(UnpinMessageJob.KEY, new UnpinMessageJob.Factory());
|
||||
put(UploadAttachmentToArchiveJob.KEY, new UploadAttachmentToArchiveJob.Factory());
|
||||
|
||||
// Migrations
|
||||
|
||||
@@ -286,6 +286,7 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
List<BodyRange> bodyRanges = getBodyRanges(message);
|
||||
Optional<SignalServiceDataMessage.PollCreate> pollCreate = getPollCreate(message);
|
||||
Optional<SignalServiceDataMessage.PollTerminate> pollTerminate = getPollTerminate(message);
|
||||
SignalServiceDataMessage.PinnedMessage pinnedMessage = getPinnedMessage(message);
|
||||
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
|
||||
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
|
||||
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
|
||||
@@ -368,7 +369,8 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
.withMentions(mentions)
|
||||
.withBodyRanges(bodyRanges)
|
||||
.withPollCreate(pollCreate.orElse(null))
|
||||
.withPollTerminate(pollTerminate.orElse(null));
|
||||
.withPollTerminate(pollTerminate.orElse(null))
|
||||
.withPinnedMessage(pinnedMessage);
|
||||
|
||||
if (message.getParentStoryId() != null) {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -30,6 +29,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactModelMapper;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
@@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
@@ -48,7 +49,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil;
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
@@ -60,9 +60,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.ImageCompressionUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress;
|
||||
@@ -77,10 +75,8 @@ import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulRespons
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer;
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
@@ -90,7 +86,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -530,6 +525,19 @@ public abstract class PushSendJob extends SendJob {
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
protected @Nullable SignalServiceDataMessage.PinnedMessage getPinnedMessage(OutgoingMessage message) {
|
||||
if (message.getMessageExtras() == null || message.getMessageExtras().pinnedMessage == null || ACI.parseOrNull(message.getMessageExtras().pinnedMessage.targetAuthorAci) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PinnedMessage pinnedMessage = message.getMessageExtras().pinnedMessage;
|
||||
if (pinnedMessage.pinDurationInSeconds == MessageTable.PIN_FOREVER) {
|
||||
return new SignalServiceDataMessage.PinnedMessage(ACI.parseOrNull(pinnedMessage.targetAuthorAci), pinnedMessage.targetTimestamp, null, true);
|
||||
} else {
|
||||
return new SignalServiceDataMessage.PinnedMessage(ACI.parseOrNull(pinnedMessage.targetAuthorAci), pinnedMessage.targetTimestamp, (int) pinnedMessage.pinDurationInSeconds, null);
|
||||
}
|
||||
}
|
||||
|
||||
protected void rotateSenderCertificateIfNecessary() throws IOException {
|
||||
try {
|
||||
Collection<CertificateType> requiredCertificateTypes = SignalStore.phoneNumberPrivacy()
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobs.protos.UnpinJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.GroupSendUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.GroupUtil
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Companion.newBuilder
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Job to unpin a message sent either to 1:1 or group chat
|
||||
*/
|
||||
class UnpinMessageJob(
|
||||
private val messageId: Long,
|
||||
private val recipientIds: MutableList<Long>,
|
||||
private val initialRecipientCount: Int,
|
||||
parameters: Parameters
|
||||
) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY: String = "UnpinMessageJob"
|
||||
private val TAG = Log.tag(UnpinMessageJob::class.java)
|
||||
|
||||
fun create(messageId: Long): UnpinMessageJob? {
|
||||
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
|
||||
if (message == null) {
|
||||
Log.w(TAG, "Unable to find corresponding message")
|
||||
return null
|
||||
}
|
||||
|
||||
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
|
||||
if (conversationRecipient == null) {
|
||||
Log.w(TAG, "We have a message, but couldn't find the thread!")
|
||||
return null
|
||||
}
|
||||
|
||||
val recipients = if (conversationRecipient.isGroup) {
|
||||
conversationRecipient.participantIds.filter { it != Recipient.self().id }.map { it.toLong() }
|
||||
} else {
|
||||
listOf(conversationRecipient.id.toLong())
|
||||
}
|
||||
|
||||
return UnpinMessageJob(
|
||||
messageId = messageId,
|
||||
recipientIds = recipients.toMutableList(),
|
||||
initialRecipientCount = recipients.size,
|
||||
parameters = Parameters.Builder()
|
||||
.setQueue(conversationRecipient.id.toQueueKey())
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setLifespan(1.days.inWholeMilliseconds)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray {
|
||||
return UnpinJobData(messageId, recipientIds, initialRecipientCount).encode()
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String {
|
||||
return KEY
|
||||
}
|
||||
|
||||
override fun run(): Result {
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
Log.w(TAG, "Not registered. Skipping.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
|
||||
if (message == null) {
|
||||
Log.w(TAG, "Unable to find corresponding message")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
|
||||
if (conversationRecipient == null) {
|
||||
Log.w(TAG, "We have a message, but couldn't find the thread!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val targetAuthor = message.fromRecipient
|
||||
if (targetAuthor == null || !targetAuthor.hasServiceId) {
|
||||
Log.w(TAG, "Unable to find target author")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val targetSentTimestamp = message.dateSent
|
||||
|
||||
val recipients = Recipient.resolvedList(recipientIds.filter { it != Recipient.self().id.toLong() }.map { RecipientId.from(it) })
|
||||
val registered = RecipientUtil.getEligibleForSending(recipients)
|
||||
val unregistered = recipients - registered.toSet()
|
||||
val completions: List<Recipient> = deliver(conversationRecipient, registered, message.threadId, targetAuthor, targetSentTimestamp)
|
||||
|
||||
recipientIds.removeAll(unregistered.map { it.id.toLong() })
|
||||
recipientIds.removeAll(completions.map { it.id.toLong() })
|
||||
|
||||
Log.i(TAG, "Completed now: " + completions.size + ", Remaining: " + recipientIds.size)
|
||||
|
||||
if (recipientIds.isNotEmpty()) {
|
||||
Log.w(TAG, "Still need to send to " + recipientIds.size + " recipients. Retrying.")
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun deliver(conversationRecipient: Recipient, destinations: List<Recipient>, threadId: Long, targetAuthor: Recipient, targetSentTimestamp: Long): List<Recipient> {
|
||||
val dataMessageBuilder = newBuilder()
|
||||
.withTimestamp(System.currentTimeMillis())
|
||||
.withUnpinnedMessage(
|
||||
SignalServiceDataMessage.UnpinnedMessage(
|
||||
targetAuthor = targetAuthor.requireServiceId(),
|
||||
targetSentTimestamp = targetSentTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
if (conversationRecipient.isGroup) {
|
||||
GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush())
|
||||
}
|
||||
|
||||
val dataMessage = dataMessageBuilder.build()
|
||||
|
||||
val results = GroupSendUtil.sendResendableDataMessage(
|
||||
context,
|
||||
conversationRecipient.groupId.map { obj: GroupId -> obj.requireV2() }.orElse(null),
|
||||
null,
|
||||
destinations,
|
||||
false,
|
||||
ContentHint.RESENDABLE,
|
||||
MessageId(messageId),
|
||||
dataMessage,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
)
|
||||
|
||||
val result = GroupSendJobHelper.getCompletedSends(destinations, results)
|
||||
|
||||
for (unregistered in result.unregistered) {
|
||||
SignalDatabase.recipients.markUnregistered(unregistered)
|
||||
}
|
||||
|
||||
if (result.completed.isNotEmpty() || destinations.isEmpty()) {
|
||||
SignalDatabase.messages.unpinMessage(
|
||||
messageId = messageId,
|
||||
threadId = threadId
|
||||
)
|
||||
}
|
||||
|
||||
return result.completed
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
if (recipientIds.size < initialRecipientCount) {
|
||||
Log.w(TAG, "Only sent unpinned to " + recipientIds.size + "/" + initialRecipientCount + " recipients.")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to send to all recipients.")
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<UnpinMessageJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): UnpinMessageJob {
|
||||
val data = UnpinJobData.ADAPTER.decode(serializedData!!)
|
||||
|
||||
return UnpinMessageJob(
|
||||
messageId = data.messageId,
|
||||
recipientIds = data.recipients.toMutableList(),
|
||||
initialRecipientCount = data.initialRecipientCount,
|
||||
parameters = parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import java.util.List;
|
||||
public class UiHintValues extends SignalStoreValues {
|
||||
|
||||
private static final int NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD = 3;
|
||||
private static final int HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD = 3;
|
||||
|
||||
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
|
||||
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
|
||||
@@ -29,6 +30,7 @@ public class UiHintValues extends SignalStoreValues {
|
||||
private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet";
|
||||
private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet";
|
||||
private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning";
|
||||
private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet";
|
||||
|
||||
UiHintValues(@NonNull KeyValueStore store) {
|
||||
super(store);
|
||||
@@ -218,4 +220,16 @@ public class UiHintValues extends SignalStoreValues {
|
||||
public void markDismissedSaveStorageWarning() {
|
||||
putBoolean(HAS_DISMISSED_SAVE_STORAGE_WARNING, true);
|
||||
}
|
||||
|
||||
public boolean shouldDisplayPinnedSheet() {
|
||||
return getSeenPinnedSheetCount() < HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD;
|
||||
}
|
||||
|
||||
public void incrementSeenPinnedSheetCount() {
|
||||
putInteger(HAS_SEEN_PINNED_MESSAGE_SHEET, getSeenPinnedSheetCount() + 1);
|
||||
}
|
||||
|
||||
private int getSeenPinnedSheetCount() {
|
||||
return getInteger(HAS_SEEN_PINNED_MESSAGE_SHEET, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,10 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMessageDetailsFragmentDismissed()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.contactshare.ContactModelMapper
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.SecurityEvent
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable.InsertResult
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException
|
||||
@@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.toBodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -178,6 +180,8 @@ object DataMessageProcessor {
|
||||
message.pollCreate != null -> insertResult = handlePollCreate(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime)
|
||||
message.pollTerminate != null -> insertResult = handlePollTerminate(context, envelope, metadata, message, senderRecipient, earlyMessageCacheEntry, threadRecipient, groupId, receivedTime)
|
||||
message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry)
|
||||
message.pinMessage != null -> insertResult = handlePinMessage(envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, earlyMessageCacheEntry)
|
||||
message.unpinMessage != null -> messageId = handleUnpinMessage(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry)
|
||||
}
|
||||
|
||||
messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) }
|
||||
@@ -1247,6 +1251,150 @@ object DataMessageProcessor {
|
||||
return messageId
|
||||
}
|
||||
|
||||
fun handlePinMessage(
|
||||
envelope: Envelope,
|
||||
metadata: EnvelopeMetadata,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
threadRecipient: Recipient,
|
||||
groupId: GroupId.V2?,
|
||||
receivedTime: Long,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null
|
||||
): InsertResult? {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Pinned message not allowed due to remote config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val pinMessage = message.pinMessage!!
|
||||
log(envelope.timestamp!!, "[handlePinMessage] Pin message for " + pinMessage.targetSentTimestamp)
|
||||
|
||||
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
|
||||
|
||||
val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(pinMessage.targetAuthorAciBinary!!)
|
||||
if (targetAuthorServiceId.isUnknown) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Unknown target author! Ignoring the message.")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetAuthor = Recipient.externalPush(targetAuthorServiceId)
|
||||
|
||||
val targetMessage: MmsMessageRecord? = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp!!, targetAuthor.id) as? MmsMessageRecord
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Could not find matching message! Putting it in the early message cache. timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(targetAuthor.id, pinMessage.targetSentTimestamp!!, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetMessage.isRemoteDelete) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Found a matching message, but it's flagged as remotely deleted. timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
|
||||
if (targetThread == null) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Could not find a thread for the message! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull()
|
||||
if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Sender is not in the group! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (groupRecord == null && senderRecipient.id != threadRecipient.id && Recipient.self().id != senderRecipient.id) {
|
||||
warn(envelope.timestamp!!, "[handlePinMessage] Sender is not a part of the 1:1 thread! timestamp: ${pinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val duration = if (pinMessage.pinDurationForever == true) MessageTable.PIN_FOREVER else pinMessage.pinDurationSeconds!!.toLong()
|
||||
val pinnedMessage = IncomingMessage(
|
||||
type = MessageType.PINNED_MESSAGE,
|
||||
from = senderRecipient.id,
|
||||
sentTimeMillis = envelope.timestamp!!,
|
||||
serverTimeMillis = envelope.serverTimestamp!!,
|
||||
receivedTimeMillis = receivedTime,
|
||||
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
|
||||
groupId = groupId,
|
||||
isUnidentified = metadata.sealedSender,
|
||||
serverGuid = UuidUtil.getStringUUID(envelope.serverGuid, envelope.serverGuidBinary),
|
||||
messageExtras = MessageExtras(pinnedMessage = PinnedMessage(pinnedMessageId = targetMessage.id, targetAuthorAci = pinMessage.targetAuthorAciBinary!!, targetTimestamp = pinMessage.targetSentTimestamp!!, pinDurationInSeconds = duration))
|
||||
)
|
||||
|
||||
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pinnedMessage).orNull()
|
||||
|
||||
return if (insertResult != null) {
|
||||
log(envelope.timestamp!!, "Inserted a pinned message update at ${insertResult.messageId}")
|
||||
insertResult
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUnpinMessage(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
threadRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null
|
||||
): MessageId? {
|
||||
if (!RemoteConfig.receivePinnedMessages) {
|
||||
log(envelope.timestamp!!, "Unpinning message is not allowed due to remote config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val unpinMessage = message.unpinMessage!!
|
||||
log(envelope.timestamp!!, "[handleUnpinMessage] Unpin message for ${unpinMessage.targetSentTimestamp}")
|
||||
|
||||
val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(unpinMessage.targetAuthorAciBinary!!)
|
||||
if (targetAuthorServiceId.isUnknown) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Unknown target author! Ignoring the message.")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetAuthor = Recipient.externalPush(targetAuthorServiceId)
|
||||
|
||||
val targetMessage: MmsMessageRecord? = SignalDatabase.messages.getMessageFor(unpinMessage.targetSentTimestamp!!, targetAuthor.id) as? MmsMessageRecord
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Could not find matching message! Putting it in the early message cache. timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(targetAuthor.id, unpinMessage.targetSentTimestamp!!, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetMessage.isRemoteDelete) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Found a matching message, but it's flagged as remotely deleted. timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
|
||||
if (targetThread == null) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Could not find a thread for the message! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull()
|
||||
if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not in the group! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (groupRecord == null && senderRecipient.id != threadRecipient.id && Recipient.self().id != senderRecipient.id) {
|
||||
warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not a part of the 1:1 thread! timestamp: ${unpinMessage.targetSentTimestamp}")
|
||||
return null
|
||||
}
|
||||
|
||||
SignalDatabase.messages.unpinMessage(targetMessage.id, targetMessage.threadId)
|
||||
|
||||
return MessageId(targetMessage.id)
|
||||
}
|
||||
|
||||
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
|
||||
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
|
||||
|
||||
|
||||
@@ -499,6 +499,17 @@ data class OutgoingMessage(
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pinMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, messageExtras: MessageExtras): OutgoingMessage {
|
||||
return OutgoingMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = sentTimeMillis,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras,
|
||||
isSecure = true
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun quickReply(
|
||||
threadRecipient: Recipient,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.service
|
||||
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Manages waking up and unpinning pinned messages at the correct time
|
||||
*/
|
||||
class PinnedMessageManager(
|
||||
val application: Application
|
||||
) : TimedEventManager<PinnedMessageManager.Event>(application, "PinnedMessagesManager") {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessageManager::class.java)
|
||||
}
|
||||
|
||||
private val messagesTable = SignalDatabase.messages
|
||||
|
||||
init {
|
||||
scheduleIfNecessary()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getNextClosestEvent(): Event? {
|
||||
val oldestMessage: MmsMessageRecord? = messagesTable.getOldestExpiringPinnedMessageTimestamp() as? MmsMessageRecord
|
||||
|
||||
if (oldestMessage == null) {
|
||||
cancelAlarm(application, PinnedMessagesAlarm::class.java)
|
||||
return null
|
||||
}
|
||||
|
||||
val delay = (oldestMessage.pinnedUntil - System.currentTimeMillis()).coerceAtLeast(0)
|
||||
Log.i(TAG, "The next pinned message needs to be unpinned in $delay ms.")
|
||||
|
||||
return Event(delay, oldestMessage.toRecipient.id, oldestMessage.threadId)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun executeEvent(event: Event) {
|
||||
val pinnedMessagesToUnpin = messagesTable.getPinnedMessagesBefore(System.currentTimeMillis())
|
||||
for (record in pinnedMessagesToUnpin) {
|
||||
messagesTable.unpinMessage(messageId = record.id, threadId = record.threadId)
|
||||
// TODO(michelle): Send sync message to linked device to unpin message (done to ensure consistency)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getDelayForEvent(event: Event): Long = event.delay
|
||||
|
||||
@WorkerThread
|
||||
override fun scheduleAlarm(application: Application, event: Event, delay: Long) {
|
||||
val conversationIntent = ConversationIntents.createBuilderSync(application, event.recipientId, event.threadId).build()
|
||||
|
||||
trySetExactAlarm(
|
||||
application,
|
||||
System.currentTimeMillis() + delay,
|
||||
PinnedMessagesAlarm::class.java,
|
||||
PendingIntent.getActivity(application, 0, conversationIntent, PendingIntentFlags.mutable())
|
||||
)
|
||||
}
|
||||
|
||||
data class Event(val delay: Long, val recipientId: RecipientId, val threadId: Long)
|
||||
|
||||
class PinnedMessagesAlarm : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PinnedMessagesAlarm::class.java)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
Log.d(TAG, "onReceive()")
|
||||
AppDependencies.pinnedMessageManager.scheduleIfNecessary()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,32 +23,23 @@ fun MessageRecord.isMediaMessage(): Boolean {
|
||||
slideDeck.stickerSlide == null
|
||||
}
|
||||
|
||||
fun MessageRecord.hasNonTextSlide(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.slides.any { slide -> slide !is TextSlide }
|
||||
fun MessageRecord.hasNonTextSlide(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.slides.any { slide -> slide !is TextSlide }
|
||||
|
||||
fun MessageRecord.hasSticker(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.stickerSlide != null
|
||||
fun MessageRecord.hasSticker(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.stickerSlide != null
|
||||
|
||||
fun MessageRecord.hasSharedContact(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).sharedContacts.isNotEmpty()
|
||||
fun MessageRecord.hasSharedContact(): Boolean = isMms && (this as MmsMessageRecord).sharedContacts.isNotEmpty()
|
||||
|
||||
fun MessageRecord.hasLocation(): Boolean =
|
||||
isMms && ((this as MmsMessageRecord).slideDeck.slides).any { slide -> slide.hasLocation() }
|
||||
fun MessageRecord.hasLocation(): Boolean = isMms && ((this as MmsMessageRecord).slideDeck.slides).any { slide -> slide.hasLocation() }
|
||||
|
||||
fun MessageRecord.hasAudio(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.audioSlide != null
|
||||
fun MessageRecord.hasAudio(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.audioSlide != null
|
||||
|
||||
fun MessageRecord.isCaptionlessMms(context: Context): Boolean =
|
||||
isMms && isDisplayBodyEmpty(context) && (this as MmsMessageRecord).slideDeck.textSlide == null
|
||||
fun MessageRecord.isCaptionlessMms(context: Context): Boolean = isMms && isDisplayBodyEmpty(context) && (this as MmsMessageRecord).slideDeck.textSlide == null
|
||||
|
||||
fun MessageRecord.hasThumbnail(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null
|
||||
fun MessageRecord.hasThumbnail(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null
|
||||
|
||||
fun MessageRecord.isStoryReaction(): Boolean =
|
||||
isMms && MessageTypes.isStoryReaction(type)
|
||||
fun MessageRecord.isStoryReaction(): Boolean = isMms && MessageTypes.isStoryReaction(type)
|
||||
|
||||
fun MessageRecord.isStory(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).storyType.isStory
|
||||
fun MessageRecord.isStory(): Boolean = isMms && (this as MmsMessageRecord).storyType.isStory
|
||||
|
||||
fun MessageRecord.isBorderless(context: Context): Boolean {
|
||||
return isCaptionlessMms(context) &&
|
||||
@@ -56,8 +47,7 @@ fun MessageRecord.isBorderless(context: Context): Boolean {
|
||||
(this as MmsMessageRecord).slideDeck.thumbnailSlide?.isBorderless == true
|
||||
}
|
||||
|
||||
fun MessageRecord.hasNoBubble(context: Context): Boolean =
|
||||
hasSticker() || isBorderless(context) || (isTextOnly(context) && isJumbomoji(context) && (messageRanges?.ranges?.isEmpty() ?: true))
|
||||
fun MessageRecord.hasNoBubble(context: Context): Boolean = hasSticker() || isBorderless(context) || (isTextOnly(context) && isJumbomoji(context) && (messageRanges?.ranges?.isEmpty() ?: true))
|
||||
|
||||
fun MessageRecord.hasOnlyThumbnail(context: Context): Boolean {
|
||||
return hasThumbnail() &&
|
||||
@@ -69,11 +59,9 @@ fun MessageRecord.hasOnlyThumbnail(context: Context): Boolean {
|
||||
!isViewOnceMessage()
|
||||
}
|
||||
|
||||
fun MessageRecord.hasDocument(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.documentSlide != null
|
||||
fun MessageRecord.hasDocument(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.documentSlide != null
|
||||
|
||||
fun MessageRecord.isViewOnceMessage(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).isViewOnce
|
||||
fun MessageRecord.isViewOnceMessage(): Boolean = isMms && (this as MmsMessageRecord).isViewOnce
|
||||
|
||||
fun MessageRecord.hasExtraText(): Boolean {
|
||||
val hasTextSlide = isMms && (this as MmsMessageRecord).slideDeck.textSlide != null
|
||||
@@ -82,24 +70,19 @@ fun MessageRecord.hasExtraText(): Boolean {
|
||||
return hasTextSlide || hasOverflowText
|
||||
}
|
||||
|
||||
fun MessageRecord.hasQuote(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).quote != null
|
||||
fun MessageRecord.hasQuote(): Boolean = isMms && (this as MmsMessageRecord).quote != null
|
||||
|
||||
fun MessageRecord.getQuote(): Quote? =
|
||||
if (isMms) {
|
||||
(this as MmsMessageRecord).quote
|
||||
} else {
|
||||
null
|
||||
}
|
||||
fun MessageRecord.getQuote(): Quote? = if (isMms) {
|
||||
(this as MmsMessageRecord).quote
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun MessageRecord.hasLinkPreview(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).linkPreviews.isNotEmpty()
|
||||
fun MessageRecord.hasLinkPreview(): Boolean = isMms && (this as MmsMessageRecord).linkPreviews.isNotEmpty()
|
||||
|
||||
fun MessageRecord.hasTextSlide(): Boolean =
|
||||
isMms && (this as MmsMessageRecord).slideDeck.textSlide != null && this.slideDeck.textSlide?.uri != null
|
||||
fun MessageRecord.hasTextSlide(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.textSlide != null && this.slideDeck.textSlide?.uri != null
|
||||
|
||||
fun MessageRecord.requireTextSlide(): TextSlide =
|
||||
requireNotNull((this as MmsMessageRecord).slideDeck.textSlide)
|
||||
fun MessageRecord.requireTextSlide(): TextSlide = requireNotNull((this as MmsMessageRecord).slideDeck.textSlide)
|
||||
|
||||
fun MessageRecord.hasPoll(): Boolean = isMms && (this as MmsMessageRecord).poll != null
|
||||
|
||||
@@ -107,6 +90,8 @@ fun MessageRecord.getPoll(): PollRecord? = if (isMms) (this as MmsMessageRecord)
|
||||
|
||||
fun MessageRecord.hasPollTerminate(): Boolean = this.isPollTerminate && this.messageExtras != null && this.messageExtras!!.pollTerminate != null
|
||||
|
||||
fun MessageRecord.hasPinnedMessageUpdate(): Boolean = this.isPinnedMessageUpdate && this.messageExtras != null && this.messageExtras!!.pinnedMessage != null
|
||||
|
||||
fun MessageRecord.hasBigImageLinkPreview(context: Context): Boolean {
|
||||
if (!hasLinkPreview()) {
|
||||
return false
|
||||
|
||||
@@ -1205,5 +1205,29 @@ object RemoteConfig {
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("pinLimit")
|
||||
val pinLimit: Int by remoteInt(
|
||||
key = "global.pinnedMessageLimit",
|
||||
defaultValue = 3,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("receivePinnedMessages")
|
||||
val receivePinnedMessages: Boolean by remoteBoolean(
|
||||
key = "android.receivePinnedMessages",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("sendPinnedMessages")
|
||||
val sendPinnedMessages: Boolean by remoteBoolean(
|
||||
key = "android.sendPinnedMessages",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
// endregion
|
||||
}
|
||||
|
||||
@@ -535,6 +535,7 @@ message MessageExtras {
|
||||
ProfileChangeDetails profileChangeDetails = 3;
|
||||
PaymentTombstone paymentTombstone = 4;
|
||||
PollTerminate pollTerminate = 5;
|
||||
PinnedMessage pinnedMessage = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,6 +556,13 @@ message PollTerminate {
|
||||
uint64 targetTimestamp = 3;
|
||||
}
|
||||
|
||||
message PinnedMessage {
|
||||
uint64 pinnedMessageId = 1;
|
||||
bytes targetAuthorAci = 2;
|
||||
uint64 targetTimestamp = 3;
|
||||
uint64 pinDurationInSeconds = 4; // Long.MAX_VALUE if pin is forever
|
||||
}
|
||||
|
||||
message LocalRegistrationMetadata {
|
||||
bytes aciIdentityKeyPair = 1;
|
||||
bytes aciSignedPreKey = 2;
|
||||
|
||||
@@ -252,3 +252,9 @@ message PollVoteJobData {
|
||||
bool isRemoval = 5;
|
||||
uint64 optionId = 6;
|
||||
}
|
||||
|
||||
message UnpinJobData {
|
||||
uint64 messageId = 1;
|
||||
repeated uint64 recipients = 2;
|
||||
uint32 initialRecipientCount = 3;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="12dp"
|
||||
android:height="12dp"
|
||||
android:viewportWidth="12"
|
||||
android:viewportHeight="12">
|
||||
<path
|
||||
android:pathData="M7.471,0.739C7.734,0.719 8.007,0.821 8.208,1.022L10.979,3.793C11.179,3.993 11.281,4.266 11.262,4.529C11.242,4.798 11.092,5.055 10.805,5.184C10.233,5.442 9.588,5.499 8.974,5.361L7.82,6.857C8.188,7.835 8.173,8.886 7.726,9.742C7.584,10.014 7.329,10.151 7.065,10.165C6.806,10.179 6.541,10.077 6.344,9.881L4.607,8.143L2.042,10.708C1.932,10.818 1.798,10.901 1.651,10.95L0.987,11.171C0.89,11.204 0.797,11.111 0.829,11.013L1.05,10.349C1.1,10.202 1.182,10.068 1.292,9.958L3.857,7.393L2.12,5.656C1.923,5.46 1.821,5.194 1.835,4.936C1.849,4.672 1.986,4.416 2.258,4.274C3.114,3.827 4.165,3.813 5.144,4.181L6.64,3.026C6.502,2.412 6.558,1.768 6.816,1.195C6.946,0.908 7.203,0.759 7.471,0.739Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.8"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="80dp"
|
||||
android:height="80dp"
|
||||
android:viewportWidth="80"
|
||||
android:viewportHeight="80">
|
||||
<path
|
||||
android:pathData="M40,22.5C41.381,22.5 42.5,23.619 42.5,25L42.5,39.836C43.994,40.7 45,42.316 45,44.167C45,46.928 42.761,49.167 40,49.167C37.239,49.167 35,46.928 35,44.167C35,42.316 36.006,40.7 37.5,39.836L37.5,25C37.5,23.619 38.619,22.5 40,22.5Z"
|
||||
android:fillColor="#3B45FD"/>
|
||||
<path
|
||||
android:pathData="M40,2.5C41.381,2.5 42.5,3.619 42.5,5V9.261C49.595,9.801 56.054,12.619 61.145,16.985L64.159,13.972C65.135,12.995 66.718,12.995 67.695,13.972C68.671,14.948 68.671,16.531 67.695,17.507L64.681,20.521C69.555,26.205 72.5,33.592 72.5,41.667C72.5,59.616 57.949,74.167 40,74.167C22.051,74.167 7.5,59.616 7.5,41.667C7.5,24.559 20.719,10.538 37.5,9.261V5C37.5,3.619 38.619,2.5 40,2.5ZM59.424,22.2L59.445,22.221L59.466,22.242C64.431,27.217 67.5,34.083 67.5,41.667C67.5,56.854 55.188,69.167 40,69.167C24.812,69.167 12.5,56.854 12.5,41.667C12.5,26.479 24.812,14.167 40,14.167C47.583,14.167 54.449,17.236 59.424,22.2Z"
|
||||
android:fillColor="#3B45FD"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -54,7 +54,7 @@
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/transparent_white_20"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/footer_date"
|
||||
app:layout_constraintEnd_toStartOf="@id/footer_pinned"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toEndOf="@id/footer_revealed_dot"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
@@ -62,6 +62,17 @@
|
||||
tools:text="1x"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/footer_pinned"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/footer_date"
|
||||
android:src="@drawable/symbol_pin_filled_12"
|
||||
android:visibility="gone"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/footer_date"
|
||||
style="@style/Signal.Text.Caption.MessageSent"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/pinned_message_compose_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/anchor"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="2dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/signal_icon_tint_tab_unselected"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"/>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/video_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/pinned_list"
|
||||
app:layout_constraintEnd_toEndOf="@id/pinned_list"
|
||||
app:layout_constraintStart_toStartOf="@id/pinned_list"
|
||||
app:layout_constraintTop_toTopOf="@id/pinned_list" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/pinned_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="40dp"
|
||||
android:clipToPadding="false"
|
||||
app:layout_constraintTop_toBottomOf="@id/anchor"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/unpin_all"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:paddingVertical="8dp"
|
||||
android:background="@color/signal_colorSurface"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:text="@string/PinnedMessage__unpin_all_messages"
|
||||
android:textAppearance="@style/Signal.Text.LabelLarge"
|
||||
android:textColor="@color/conversation_ultramarine"
|
||||
android:textAlignment="center"
|
||||
/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -190,6 +190,13 @@
|
||||
android:inflatedId="@+id/banner_compose_view"
|
||||
android:layout="@layout/conversation_list_banner_view" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/pinned_message_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inflatedId="@+id/pinned_message_compose_view"
|
||||
android:layout="@layout/pinned_message_stub" />
|
||||
|
||||
</org.thoughtcrime.securesms.conversation.v2.ConversationBannerView>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@@ -161,11 +161,23 @@
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintEnd_toEndOf="@id/conversation_item_expiration_timer"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_pinned"
|
||||
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
|
||||
tools:background="@color/blue_500"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_item_footer_pinned"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="@dimen/message_bubble_footer_bottom_padding"
|
||||
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_date"
|
||||
android:visibility="gone"
|
||||
android:src="@drawable/symbol_pin_filled_12" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_item_footer_date"
|
||||
style="@style/Signal.Text.Caption.MessageSent"
|
||||
|
||||
@@ -136,10 +136,22 @@
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/barrier_footer_bottom"
|
||||
app:layout_constraintEnd_toEndOf="@id/conversation_item_delivery_status"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_pinned"
|
||||
app:layout_constraintTop_toTopOf="@id/barrier_footer_top"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_item_footer_pinned"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="@dimen/message_bubble_footer_bottom_padding"
|
||||
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_date"
|
||||
android:visibility="gone"
|
||||
android:src="@drawable/symbol_pin_filled_12" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_item_footer_date"
|
||||
style="@style/Signal.Text.Caption.MessageSent"
|
||||
|
||||
@@ -121,11 +121,23 @@
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintEnd_toEndOf="@id/conversation_item_expiration_timer"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_pinned"
|
||||
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
|
||||
tools:background="@color/blue_500"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_item_footer_pinned"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="@dimen/message_bubble_footer_bottom_padding"
|
||||
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_date"
|
||||
android:visibility="gone"
|
||||
android:src="@drawable/symbol_pin_filled_12" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_item_footer_date"
|
||||
style="@style/Signal.Text.Caption.MessageSent"
|
||||
|
||||
@@ -82,14 +82,14 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="top"
|
||||
app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date" />
|
||||
app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date,conversation_item_footer_pinned" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/barrier_footer_bottom"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="bottom"
|
||||
app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date" />
|
||||
app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date,conversation_item_footer_pinned" />
|
||||
|
||||
<View
|
||||
android:id="@+id/conversation_item_footer_background"
|
||||
@@ -102,10 +102,22 @@
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/barrier_footer_bottom"
|
||||
app:layout_constraintEnd_toEndOf="@id/conversation_item_delivery_status"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_pinned"
|
||||
app:layout_constraintTop_toTopOf="@id/barrier_footer_top"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_item_footer_pinned"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="@dimen/message_bubble_footer_bottom_padding"
|
||||
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
|
||||
app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_date"
|
||||
android:visibility="gone"
|
||||
android:src="@drawable/symbol_pin_filled_12" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_item_footer_date"
|
||||
style="@style/Signal.Text.Caption.MessageSent"
|
||||
|
||||
@@ -405,4 +405,16 @@
|
||||
<item>@string/PlaybackSpeedToggleTextView__p5x</item>
|
||||
</string-array>
|
||||
|
||||
<integer-array name="ConversationFragment__pinned_for_values">
|
||||
<item>1</item>
|
||||
<item>7</item>
|
||||
<item>30</item>
|
||||
<item>-1</item>
|
||||
</integer-array>
|
||||
<string-array name="ConversationFragment__pinned_for_labels">
|
||||
<item>@string/ConversationFragment__24_hours</item>
|
||||
<item>@string/ConversationFragment__7_days</item>
|
||||
<item>@string/ConversationFragment__30_days</item>
|
||||
<item>@string/ConversationFragment__forever</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -682,6 +682,22 @@
|
||||
<string name="ConversationFragment_group_names">Group names</string>
|
||||
<!-- Snackbar toast message shown when a profile cannot be downloaded and to try again. -->
|
||||
<string name="ConversationFragment_photo_failed">Photo failed to download. Try again.</string>
|
||||
<!-- Dialog for how to long to keep a messaged pinned for -->
|
||||
<string name="ConversationFragment__keep_pinned">Keep pinned for…</string>
|
||||
<!-- Dialog option to keep message pin for 24 hours -->
|
||||
<string name="ConversationFragment__24_hours">24 hours</string>
|
||||
<!-- Dialog option to keep message pin for 7 days -->
|
||||
<string name="ConversationFragment__7_days">7 days</string>
|
||||
<!-- Dialog option to keep message pin for 30 days -->
|
||||
<string name="ConversationFragment__30_days">30 days</string>
|
||||
<!-- Dialog option to keep message pin forever -->
|
||||
<string name="ConversationFragment__forever">Forever</string>
|
||||
<!-- Dialog title when replacing the oldest pin -->
|
||||
<string name="ConversationFragment__replace_title">Replace oldest pin?</string>
|
||||
<!-- Dialog body when replacing the oldest pin -->
|
||||
<string name="ConversationFragment__replace_body">Pinning message will replace the oldest one</string>
|
||||
<!-- Dialog button when replacing the oldest pin -->
|
||||
<string name="ConversationFragment__replace">Replace</string>
|
||||
|
||||
<!-- Title of Safety Tips bottom sheet dialog -->
|
||||
<string name="SafetyTips_title">Safety Tips</string>
|
||||
@@ -4466,6 +4482,10 @@
|
||||
<string name="conversation_selection__menu_payment_details">Payment details</string>
|
||||
<!-- Button to end a poll -->
|
||||
<string name="conversation_selection__menu_end_poll">End poll</string>
|
||||
<!-- Button to pin a message -->
|
||||
<string name="conversation_selection__menu_pin_message">Pin message</string>
|
||||
<!-- Button to unpin a message -->
|
||||
<string name="conversation_selection__menu_unpin_message">Unpin message</string>
|
||||
|
||||
<!-- conversation_expiring_on -->
|
||||
|
||||
@@ -9006,5 +9026,42 @@
|
||||
<!-- Displayed as a snackbar after submitting feedback -->
|
||||
<string name="CallQualitySheet__thanks_for_your_feedback">Thanks for your feedback!</string>
|
||||
|
||||
<!-- Message body when someone pins a message where %1$s is the name of the person -->
|
||||
<string name="PinnedMessage__s_pinned_a_message">%1$s pinned a message</string>
|
||||
<!-- Message body when you pin a message -->
|
||||
<string name="PinnedMessage__you_pinned_a_message">You pinned a message</string>
|
||||
<!-- Button body to go to the pinned message -->
|
||||
<string name="PinnedMessage__go_to_message">Go to message</string>
|
||||
<!-- Toast shown when a pinned message is not found -->
|
||||
<string name="PinnedMessage__not_found">Message not found</string>
|
||||
<!-- Content description for the pin icon -->
|
||||
<string name="PinnedMessage__pinned">Pinned</string>
|
||||
<!-- Caption shown when the pinned message is a photo -->
|
||||
<string name="PinnedMessage__photo">Photo</string>
|
||||
<!-- Caption shown when the pinned message is a video -->
|
||||
<string name="PinnedMessage__video">Video</string>
|
||||
<!-- Caption shown when the pinned message is a sticker -->
|
||||
<string name="PinnedMessage__sticker">Sticker</string>
|
||||
<!-- Caption shown when the pinned message is a voice message -->
|
||||
<string name="PinnedMessage__voice">Voice message</string>
|
||||
<!-- Caption shown when the pinned message is a gif -->
|
||||
<string name="PinnedMessage__gif">GIF</string>
|
||||
<!-- Caption shown when the pinned message is a view once media -->
|
||||
<string name="PinnedMessage__view_once">View-once media</string>
|
||||
<!-- Context menu option to unpin the message -->
|
||||
<string name="PinnedMessage__unpin_message">Unpin message</string>
|
||||
<!-- Context menu option to view all the message -->
|
||||
<string name="PinnedMessage__view_all_messages">View all messages</string>
|
||||
<!-- Text to unpin all messages -->
|
||||
<string name="PinnedMessage__unpin_all_messages">Unpin All Messages</string>
|
||||
<!-- Title of bottom sheet when pinning a disappearing message -->
|
||||
<string name="PinnedMessage__disappearing_message_title">Pinning disappearing messages</string>
|
||||
<!-- Body of bottom sheet when pinning a disappearing message -->
|
||||
<string name="PinnedMessage__disappearing_message_body">Disappearing messages will be unpinned when their timer expires and the message is removed from the chat.</string>
|
||||
<!-- Confirmation button for bottom sheet -->
|
||||
<string name="PinnedMessage__got_it">Got it</string>
|
||||
<!-- Content description of disappearing timer icon -->
|
||||
<string name="PinnedMessage__disappearing_message_content_description">Disappearing message</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
+5
@@ -23,6 +23,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,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun providePinnedMessageManager(): PinnedMessageManager {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ object FakeMessageRecords {
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,22 +5,33 @@
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -81,6 +92,36 @@ object DropdownMenus {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly styled menu item with a leading icon
|
||||
*/
|
||||
@Composable
|
||||
fun ItemWithIcon(
|
||||
menuController: MenuController,
|
||||
@DrawableRes drawableResId: Int,
|
||||
@StringRes stringResId: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = {
|
||||
onClick()
|
||||
menuController.hide()
|
||||
})
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = drawableResId),
|
||||
contentDescription = stringResource(stringResId)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(stringResId),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu controller to hold menu display state and allow other components
|
||||
* to show and hide it.
|
||||
|
||||
+25
@@ -1279,6 +1279,31 @@ public class SignalServiceMessageSender {
|
||||
.build());
|
||||
}
|
||||
|
||||
if (message.getPinnedMessage().isPresent()) {
|
||||
SignalServiceDataMessage.PinnedMessage pinnedMessage = message.getPinnedMessage().get();
|
||||
if (Boolean.TRUE.equals(pinnedMessage.getForever())) {
|
||||
builder.pinMessage(new DataMessage.PinMessage.Builder()
|
||||
.targetAuthorAciBinary(pinnedMessage.getTargetAuthor().toByteString())
|
||||
.targetSentTimestamp(pinnedMessage.getTargetSentTimestamp())
|
||||
.pinDurationForever(true)
|
||||
.build());
|
||||
} else {
|
||||
builder.pinMessage(new DataMessage.PinMessage.Builder()
|
||||
.targetAuthorAciBinary(pinnedMessage.getTargetAuthor().toByteString())
|
||||
.targetSentTimestamp(pinnedMessage.getTargetSentTimestamp())
|
||||
.pinDurationSeconds(pinnedMessage.getPinDurationInSeconds())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
if (message.getUnpinnedMessage().isPresent()) {
|
||||
SignalServiceDataMessage.UnpinnedMessage unpinnedMessage = message.getUnpinnedMessage().get();
|
||||
builder.unpinMessage(new DataMessage.UnpinMessage.Builder()
|
||||
.targetAuthorAciBinary(unpinnedMessage.getTargetAuthor().toByteString())
|
||||
.targetSentTimestamp(unpinnedMessage.getTargetSentTimestamp())
|
||||
.build());
|
||||
}
|
||||
|
||||
builder.timestamp(message.getTimestamp());
|
||||
|
||||
return builder;
|
||||
|
||||
+8
@@ -155,6 +155,14 @@ object EnvelopeContentValidator {
|
||||
return Result.Invalid("[DataMessage] Invalid poll vote!")
|
||||
}
|
||||
|
||||
if (dataMessage.pinMessage != null && (dataMessage.pinMessage.targetAuthorAciBinary.isNullOrInvalidAci() || dataMessage.pinMessage.targetSentTimestamp == null || (dataMessage.pinMessage.pinDurationSeconds == null && dataMessage.pinMessage.pinDurationForever == null))) {
|
||||
return Result.Invalid("[DataMessage] Invalid pin message!")
|
||||
}
|
||||
|
||||
if (dataMessage.unpinMessage != null && (dataMessage.unpinMessage.targetAuthorAciBinary.isNullOrInvalidAci() || dataMessage.unpinMessage.targetSentTimestamp == null)) {
|
||||
return Result.Invalid("[DataMessage] Invalid unpin message!")
|
||||
}
|
||||
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -53,7 +53,9 @@ class SignalServiceDataMessage private constructor(
|
||||
val bodyRanges: Optional<List<BodyRange>>,
|
||||
val pollCreate: Optional<PollCreate>,
|
||||
val pollVote: Optional<PollVote>,
|
||||
val pollTerminate: Optional<PollTerminate>
|
||||
val pollTerminate: Optional<PollTerminate>,
|
||||
val pinnedMessage: Optional<PinnedMessage>,
|
||||
val unpinnedMessage: Optional<UnpinnedMessage>
|
||||
) {
|
||||
val isActivatePaymentsRequest: Boolean = payment.map { it.isActivationRequest }.orElse(false)
|
||||
val isPaymentsActivated: Boolean = payment.map { it.isActivation }.orElse(false)
|
||||
@@ -74,7 +76,9 @@ class SignalServiceDataMessage private constructor(
|
||||
this.remoteDelete.isPresent ||
|
||||
this.pollCreate.isPresent ||
|
||||
this.pollVote.isPresent ||
|
||||
this.pollTerminate.isPresent
|
||||
this.pollTerminate.isPresent ||
|
||||
this.pinnedMessage.isPresent ||
|
||||
this.unpinnedMessage.isPresent
|
||||
|
||||
val isGroupV2Update: Boolean = groupContext.isPresent && groupContext.get().hasSignedGroupChange() && !hasRenderableContent
|
||||
val isEmptyGroupV2Message: Boolean = isGroupV2Message && !isGroupV2Update && !hasRenderableContent
|
||||
@@ -106,6 +110,8 @@ class SignalServiceDataMessage private constructor(
|
||||
private var pollCreate: PollCreate? = null
|
||||
private var pollVote: PollVote? = null
|
||||
private var pollTerminate: PollTerminate? = null
|
||||
private var pinnedMessage: PinnedMessage? = null
|
||||
private var unpinnedMessage: UnpinnedMessage? = null
|
||||
|
||||
fun withTimestamp(timestamp: Long): Builder {
|
||||
this.timestamp = timestamp
|
||||
@@ -244,6 +250,16 @@ class SignalServiceDataMessage private constructor(
|
||||
return this
|
||||
}
|
||||
|
||||
fun withPinnedMessage(pinnedMessage: PinnedMessage?): Builder {
|
||||
this.pinnedMessage = pinnedMessage
|
||||
return this
|
||||
}
|
||||
|
||||
fun withUnpinnedMessage(unpinnedMessage: UnpinnedMessage?): Builder {
|
||||
this.unpinnedMessage = unpinnedMessage
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): SignalServiceDataMessage {
|
||||
if (timestamp == 0L) {
|
||||
timestamp = System.currentTimeMillis()
|
||||
@@ -275,7 +291,9 @@ class SignalServiceDataMessage private constructor(
|
||||
bodyRanges = bodyRanges.asOptional(),
|
||||
pollCreate = pollCreate.asOptional(),
|
||||
pollVote = pollVote.asOptional(),
|
||||
pollTerminate = pollTerminate.asOptional()
|
||||
pollTerminate = pollTerminate.asOptional(),
|
||||
pinnedMessage = pinnedMessage.asOptional(),
|
||||
unpinnedMessage = unpinnedMessage.asOptional()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +340,8 @@ class SignalServiceDataMessage private constructor(
|
||||
data class PollCreate(val question: String, val allowMultiple: Boolean, val options: List<String>)
|
||||
data class PollVote(val targetAuthor: ServiceId, val targetSentTimestamp: Long, val optionIndexes: List<Int>, val voteCount: Int)
|
||||
data class PollTerminate(val targetSentTimestamp: Long)
|
||||
data class PinnedMessage(val targetAuthor: ServiceId, val targetSentTimestamp: Long, val pinDurationInSeconds: Int?, val forever: Boolean?)
|
||||
data class UnpinnedMessage(val targetAuthor: ServiceId, val targetSentTimestamp: Long)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
|
||||
@@ -335,6 +335,20 @@ message DataMessage {
|
||||
optional uint32 voteCount = 4; // increment this by 1 each time you vote on a given poll
|
||||
}
|
||||
|
||||
message PinMessage {
|
||||
optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
|
||||
optional uint64 targetSentTimestamp = 2;
|
||||
oneof pinDuration {
|
||||
uint32 pinDurationSeconds = 3;
|
||||
bool pinDurationForever = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message UnpinMessage {
|
||||
optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
|
||||
optional uint64 targetSentTimestamp = 2;
|
||||
}
|
||||
|
||||
optional string body = 1;
|
||||
repeated AttachmentPointer attachments = 2;
|
||||
reserved /*groupV1*/ 3;
|
||||
@@ -360,7 +374,9 @@ message DataMessage {
|
||||
optional PollCreate pollCreate = 24;
|
||||
optional PollTerminate pollTerminate = 25;
|
||||
optional PollVote pollVote = 26;
|
||||
// NEXT ID: 27
|
||||
optional PinMessage pinMessage = 27;
|
||||
optional UnpinMessage unpinMessage = 28;
|
||||
// NEXT ID: 29
|
||||
}
|
||||
|
||||
message NullMessage {
|
||||
|
||||
Reference in New Issue
Block a user