Add support for starring messages.

This commit is contained in:
Greyson Parrelli
2026-03-20 21:24:10 -04:00
committed by Cody Henthorne
parent 6496f236ea
commit 48374e6950
48 changed files with 1149 additions and 49 deletions

View File

@@ -170,6 +170,7 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment
import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.starred.StarredMessagesActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -1158,6 +1159,10 @@ class MainActivity :
toolbarViewModel.setChatFilter(ConversationFilter.OFF)
}
override fun onStarredMessagesClick() {
startActivity(StarredMessagesActivity.createIntent(this@MainActivity))
}
override fun onSettingsClick() {
openSettings.launch(AppSettingsActivity.home(this@MainActivity))
}

View File

@@ -11,6 +11,7 @@ import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.ImageView;
import android.widget.TextView;
@@ -24,6 +25,7 @@ import com.airbnb.lottie.LottieAnimationView;
import com.airbnb.lottie.LottieProperty;
import com.airbnb.lottie.model.KeyPath;
import org.signal.core.ui.view.Stub;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
@@ -56,6 +58,8 @@ public class ConversationItemFooter extends ConstraintLayout {
private ImageView insecureIndicatorView;
private DeliveryStatusView deliveryStatusView;
private ImageView pinnedView;
private Stub<ImageView> starredStub;
private int iconColor;
private boolean onlyShowSendingStatus;
private TextView audioDuration;
private LottieAnimationView revealDot;
@@ -100,6 +104,7 @@ public class ConversationItemFooter extends ConstraintLayout {
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
deliveryStatusView = findViewById(R.id.footer_delivery_status);
pinnedView = findViewById(R.id.footer_pinned);
starredStub = new Stub<>((ViewStub) findViewById(R.id.footer_starred));
audioDuration = findViewById(R.id.footer_audio_duration);
revealDot = findViewById(R.id.footer_revealed_dot);
playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle);
@@ -146,6 +151,7 @@ public class ConversationItemFooter extends ConstraintLayout {
presentDeliveryStatus(messageRecord);
presentAudioDuration(messageRecord);
presentPinnedIcon(messageRecord);
presentStarredIcon(messageRecord);
}
public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
@@ -174,10 +180,14 @@ public class ConversationItemFooter extends ConstraintLayout {
}
public void setIconColor(int color) {
iconColor = color;
timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
insecureIndicatorView.setColorFilter(color);
deliveryStatusView.setTint(color);
pinnedView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
if (starredStub.resolved()) {
starredStub.get().setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
}
public void setRevealDotColor(int color) {
@@ -449,6 +459,16 @@ public class ConversationItemFooter extends ConstraintLayout {
}
}
private void presentStarredIcon(@NonNull MessageRecord messageRecord) {
if (messageRecord.isStarred()) {
ImageView starredView = starredStub.get();
starredView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
starredView.setVisibility(View.VISIBLE);
} else {
starredStub.setVisibility(View.GONE);
}
}
private void showAudioDurationViews() {
audioDuration.setVisibility(View.VISIBLE);
revealDot.setVisibility(View.VISIBLE);

View File

@@ -13,4 +13,5 @@ sealed interface LabsSettingsEvents {
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
data class ToggleNewApngRenderer(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
}

View File

@@ -151,6 +151,15 @@ private fun LabsSettingsContent(
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleNewApngRenderer(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.starredMessages,
text = "Starred Messages",
label = "Enables starring messages for later reference. Adds star/unstar to the long-press menu and a starred messages screen accessible from conversation settings and the main menu.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStarredMessages(it)) }
)
}
}
}
}

View File

@@ -15,5 +15,6 @@ data class LabsSettingsState(
val groupSuggestionsForMembers: Boolean = false,
val betterSearch: Boolean = false,
val autoLowerHand: Boolean = false,
val newApngRenderer: Boolean = false
val newApngRenderer: Boolean = false,
val starredMessages: Boolean = false
)

View File

@@ -45,6 +45,10 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.newApngRenderer = event.enabled
_state.value = _state.value.copy(newApngRenderer = event.enabled)
}
is LabsSettingsEvents.ToggleStarredMessages -> {
SignalStore.labs.starredMessages = event.enabled
_state.value = _state.value.copy(starredMessages = event.enabled)
}
}
}
@@ -56,7 +60,8 @@ class LabsSettingsViewModel : ViewModel() {
groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers,
betterSearch = SignalStore.labs.betterSearch,
autoLowerHand = SignalStore.labs.autoLowerHand,
newApngRenderer = SignalStore.labs.newApngRenderer
newApngRenderer = SignalStore.labs.newApngRenderer,
starredMessages = SignalStore.labs.starredMessages
)
}
}

View File

@@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndR
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
@@ -100,6 +101,7 @@ import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.starred.StarredMessagesActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
@@ -634,6 +636,16 @@ class ConversationSettingsFragment :
)
}
if (!state.recipient.isReleaseNotes && SignalStore.labs.starredMessages) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__starred_messages),
icon = DSLSettingsIcon.from(R.drawable.symbol_star_outline_24),
onClick = {
startActivity(StarredMessagesActivity.createIntent(requireContext(), state.threadId))
}
)
}
state.withRecipientSettingsState { recipientState ->
when (recipientState.contactLinkState) {
ContactLinkState.OPEN -> {

View File

@@ -256,6 +256,7 @@ public class ConversationAdapter
notifyDataSetChanged();
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
switch (getItemViewType(position)) {
@@ -268,8 +269,15 @@ public class ConversationAdapter
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
int adapterPosition = holder.getAdapterPosition();
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
ConversationMessage previousMessage = null;
ConversationMessage nextMessage = null;
boolean disableClustering = displayMode instanceof ConversationItemDisplayMode.Starred;
if (!disableClustering) {
previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
}
ConversationItemDisplayMode itemDisplayMode = displayMode != null ? displayMode : ConversationItemDisplayMode.Standard.INSTANCE;

View File

@@ -46,6 +46,7 @@ import android.view.MotionEvent;
import android.view.TouchDelegate;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.Button;
import android.widget.RelativeLayout;
import android.widget.TextView;
@@ -218,6 +219,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Nullable private ConversationItemFooter stickerFooter;
@Nullable private SenderNameWithLabelView senderWithLabelView;
@Nullable private View groupSenderHolder;
@Nullable private ViewStub starredSourceStub;
@Nullable private View starredSourceWrapper;
@Nullable private TextView starredSourceView;
@Nullable private AvatarImageView starredSourceAvatar;
private AvatarImageView contactPhoto;
private AlertView alertView;
private ReactionsConversationView reactionsView;
@@ -340,6 +345,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer);
this.senderWithLabelView = findViewById(R.id.group_sender_name_with_label);
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.starredSourceStub = findViewById(R.id.conversation_item_starred_source_stub);
this.alertView = findViewById(R.id.indicators_parent);
this.contactPhoto = findViewById(R.id.contact_photo);
this.contactPhotoHolder = findViewById(R.id.contact_photo_container);
@@ -423,6 +429,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setContactPhoto(author.get());
setSenderNameAndLabel(author.get());
setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper);
setStarredSource(messageRecord, conversationMessage);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setReactions(messageRecord);
@@ -467,7 +474,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (isCondensedMode()) return super.dispatchTouchEvent(ev);
if (isSuppressedInteractionMode()) return super.dispatchTouchEvent(ev);
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
@@ -716,10 +723,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (conversationRecipient.getId().equals(modified.getId())) {
setBubbleState(messageRecord, modified, modified.getHasWallpaper(), colorizer);
boolean wallpaper = modified.getHasWallpaper() && displayMode.displayWallpaper();
setBubbleState(messageRecord, modified, wallpaper, colorizer);
if (quoteView != null) {
quoteView.setWallpaperEnabled(modified.getHasWallpaper());
quoteView.setWallpaperEnabled(wallpaper);
}
if (audioViewStub.resolved()) {
@@ -1012,6 +1020,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return isCondensedMode() && !previousMessage.isPresent();
}
/**
* Whether interactions like swipe-to-reply and direct media opening should be suppressed.
*/
private boolean isSuppressedInteractionMode() {
return isCondensedMode() || displayMode instanceof ConversationItemDisplayMode.Starred;
}
private boolean isStoryReaction(MessageRecord messageRecord) {
return MessageRecordUtil.isStoryReaction(messageRecord);
}
@@ -1625,7 +1640,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bottomEnd = 0;
}
if (isStartOfMessageCluster(current, previous, isGroupThread) && !current.isOutgoing() && isGroupThread) {
if (!(displayMode instanceof ConversationItemDisplayMode.Starred) && isStartOfMessageCluster(current, previous, isGroupThread) && !current.isOutgoing() && isGroupThread) {
topStart = 0;
topEnd = 0;
}
@@ -1941,7 +1956,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setHasBeenQuoted(@NonNull ConversationMessage message) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty() && displayMode != ConversationItemDisplayMode.EditHistory.INSTANCE) {
if (message.hasBeenQuoted() && !isSuppressedInteractionMode() && quotedIndicator != null && batchSelected.isEmpty() && displayMode != ConversationItemDisplayMode.EditHistory.INSTANCE) {
quotedIndicator.setVisibility(VISIBLE);
quotedIndicator.setOnClickListener(quotedIndicatorClickListener);
} else if (quotedIndicator != null) {
@@ -1975,7 +1990,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 || messageRecord.getPinnedUntil() > 0;
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE || messageRecord.getPinnedUntil() > 0 || messageRecord.isStarred();
}
private boolean forceGroupHeader(@NonNull MessageRecord messageRecord) {
@@ -2018,7 +2033,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@SuppressWarnings("ConstantConditions")
private void setAuthor(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, boolean hasWallpaper) {
if (isGroupThread && !current.isOutgoing()) {
if (isGroupThread && !current.isOutgoing() && !(displayMode instanceof ConversationItemDisplayMode.Starred)) {
contactPhotoHolder.setVisibility(VISIBLE);
if (!previous.isPresent() || previous.get().isUpdate() || !current.getFromRecipient().equals(previous.get().getFromRecipient()) ||
@@ -2058,6 +2073,32 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setStarredSource(@NonNull MessageRecord current, @NonNull ConversationMessage conversationMessage) {
if (starredSourceStub == null && starredSourceWrapper == null) return;
if (displayMode instanceof ConversationItemDisplayMode.Starred) {
if (starredSourceWrapper == null) {
starredSourceWrapper = starredSourceStub.inflate();
starredSourceView = starredSourceWrapper.findViewById(R.id.conversation_item_starred_source);
starredSourceAvatar = starredSourceWrapper.findViewById(R.id.conversation_item_starred_source_avatar);
starredSourceStub = null;
}
String senderName = current.getFromRecipient().getShortDisplayName(context);
String chatName = conversationMessage.getThreadRecipient().getShortDisplayName(context);
starredSourceView.setText(context.getString(R.string.StarredMessages__s_chevron_s, senderName, chatName));
starredSourceWrapper.setVisibility(VISIBLE);
if (starredSourceAvatar != null) {
starredSourceAvatar.setAvatar(requestManager, current.getFromRecipient(), false);
}
} else {
if (starredSourceWrapper != null) {
starredSourceWrapper.setVisibility(GONE);
}
}
}
private void adjustMarginsForSenderVisibility() {
boolean senderNameVisible = groupSenderHolder != null && groupSenderHolder.getVisibility() == VISIBLE;
boolean hasContentAboveBody = (quoteView != null && quoteView.getVisibility() == VISIBLE)
@@ -2183,6 +2224,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
if (displayMode instanceof ConversationItemDisplayMode.Starred) {
return true;
}
if (isGroupThread) {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
!current.getFromRecipient().equals(previous.get().getFromRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
@@ -2194,6 +2238,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode instanceof ConversationItemDisplayMode.Starred) {
return true;
}
if (isGroupThread) {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
!current.getFromRecipient().equals(next.get().getFromRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
@@ -2206,6 +2253,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isSingularMessage(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode instanceof ConversationItemDisplayMode.Starred) {
return true;
}
return isStartOfMessageCluster(current, previous, isGroupThread) && isEndOfMessageCluster(current, next, isGroupThread);
}
@@ -2784,7 +2834,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private class ThumbnailClickListener implements SlideClickListener {
public void onClick(final View v, final Slide slide) {
if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty() || (isCondensedMode() && (!slide.hasDocument() || (slide.hasDocument() && !MessageRecordUtil.isScheduled(messageRecord))))) {
if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty() || (isSuppressedInteractionMode() && (!slide.hasDocument() || (slide.hasDocument() && !MessageRecordUtil.isScheduled(messageRecord))))) {
performClick();
} else if (!canPlayContent && mediaItem != null && eventListener != null) {
eventListener.onPlayInlineContent(conversationMessage);

View File

@@ -13,6 +13,9 @@ sealed class ConversationItemDisplayMode(val messageMode: MessageMode = MessageM
/** Less length restrictions. Used to show more info in message details. */
object Detailed : ConversationItemDisplayMode()
/** Standalone messages with starred source labels. Used for starred messages. */
object Starred : ConversationItemDisplayMode()
fun displayWallpaper(): Boolean {
return this == Standard || this == Detailed
}

View File

@@ -733,6 +733,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getString(R.string.conversation_selection__menu_unpin_message), () -> handleActionItemClicked(Action.UNPIN_MESSAGE)));
}
if (menuState.shouldShowStarMessage()) {
items.add(new ActionItem(R.drawable.symbol_star_outline_24, getResources().getString(R.string.conversation_selection__menu_star), () -> handleActionItemClicked(Action.STAR_MESSAGE)));
}
if (menuState.shouldShowUnstarMessage()) {
items.add(new ActionItem(R.drawable.symbol_star_outline_24, getResources().getString(R.string.conversation_selection__menu_unstar), () -> handleActionItemClicked(Action.UNSTAR_MESSAGE)));
}
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
@@ -920,6 +928,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
DELETE,
END_POLL,
PIN_MESSAGE,
UNPIN_MESSAGE
UNPIN_MESSAGE,
STAR_MESSAGE,
UNSTAR_MESSAGE
}
}

View File

@@ -6,6 +6,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
@@ -30,6 +31,8 @@ public final class MenuState {
private final boolean pollTerminate;
private final boolean pinMessage;
private final boolean unpinMessage;
private final boolean starMessage;
private final boolean unstarMessage;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -45,6 +48,8 @@ public final class MenuState {
pollTerminate = builder.pollTerminate;
pinMessage = builder.pinMessage;
unpinMessage = builder.unpinMessage;
starMessage = builder.starMessage;
unstarMessage = builder.unstarMessage;
}
public boolean shouldShowForwardAction() {
@@ -99,6 +104,14 @@ public final class MenuState {
return unpinMessage;
}
public boolean shouldShowStarMessage() {
return starMessage;
}
public boolean shouldShowUnstarMessage() {
return unstarMessage;
}
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
@@ -121,6 +134,8 @@ public final class MenuState {
boolean hasPollTerminate = false;
boolean canPinMessage = false;
boolean canUnpinMessage = false;
boolean canStarMessage = false;
boolean canUnstarMessage = false;
for (MultiselectPart part : selectedParts) {
MessageRecord messageRecord = part.getMessageRecord();
@@ -178,6 +193,14 @@ public final class MenuState {
if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) {
canUnpinMessage = true;
}
if (SignalStore.labs().getStarredMessages() && !messageRecord.isUpdate() && !messageRecord.isRemoteDelete() && !messageRecord.isStarred()) {
canStarMessage = true;
}
if (SignalStore.labs().getStarredMessages() && messageRecord.isStarred()) {
canUnstarMessage = true;
}
}
boolean shouldShowForwardAction = !actionMessage &&
@@ -204,7 +227,9 @@ public final class MenuState {
.shouldShowEdit(false)
.shouldShowPollTerminate(false)
.shouldShowPinMessage(false)
.shouldShowUnpinMessage(false);
.shouldShowUnpinMessage(false)
.shouldShowStarMessage(false)
.shouldShowUnstarMessage(false);
} else {
MultiselectPart multiSelectRecord = selectedParts.iterator().next();
@@ -238,6 +263,8 @@ public final class MenuState {
.shouldShowPollTerminate(hasPollTerminate)
.shouldShowPinMessage(canPinMessage)
.shouldShowUnpinMessage(canUnpinMessage)
.shouldShowStarMessage(canStarMessage)
.shouldShowUnstarMessage(canUnstarMessage)
.build();
}
@@ -285,6 +312,8 @@ public final class MenuState {
private boolean pollTerminate;
private boolean pinMessage;
private boolean unpinMessage;
private boolean starMessage;
private boolean unstarMessage;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -351,6 +380,16 @@ public final class MenuState {
return this;
}
@NonNull Builder shouldShowStarMessage(boolean starMessage) {
this.starMessage = starMessage;
return this;
}
@NonNull Builder shouldShowUnstarMessage(boolean unstarMessage) {
this.unstarMessage = unstarMessage;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View File

@@ -1955,6 +1955,22 @@ class ConversationFragment :
)
}
private fun handleStarMessages(messageIds: Set<Long>) {
disposables += viewModel
.setMessagesStarred(messageIds, true)
.subscribeBy(
onError = { Log.w(TAG, "Error starring message!", it) }
)
}
private fun handleUnstarMessages(messageIds: Set<Long>) {
disposables += viewModel
.setMessagesStarred(messageIds, false)
.subscribeBy(
onError = { Log.w(TAG, "Error unstarring message!", it) }
)
}
private fun handleVideoCall() {
val recipient = viewModel.recipientSnapshot ?: return
if (!recipient.isGroup) {
@@ -2605,6 +2621,24 @@ class ConversationFragment :
)
}
if (menuState.shouldShowStarMessage()) {
items.add(
ActionItem(R.drawable.symbol_star_outline_24, resources.getString(R.string.conversation_selection__menu_star)) {
handleStarMessages(selectedParts.map { it.conversationMessage.messageRecord.id }.toSet())
finishActionMode()
}
)
}
if (menuState.shouldShowUnstarMessage()) {
items.add(
ActionItem(R.drawable.symbol_star_outline_24, resources.getString(R.string.conversation_selection__menu_unstar)) {
handleUnstarMessages(selectedParts.map { it.conversationMessage.messageRecord.id }.toSet())
finishActionMode()
}
)
}
if (menuState.shouldShowDeleteAction()) {
items.add(
ActionItem(CoreUiR.drawable.symbol_trash_24, resources.getString(R.string.conversation_selection__menu_delete)) {
@@ -4275,6 +4309,8 @@ class ConversationFragment :
ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id)
ConversationReactionOverlay.Action.PIN_MESSAGE -> handlePinMessage(conversationMessage)
ConversationReactionOverlay.Action.UNPIN_MESSAGE -> handleUnpinMessage(conversationMessage.messageRecord.id)
ConversationReactionOverlay.Action.STAR_MESSAGE -> handleStarMessages(setOf(conversationMessage.messageRecord.id))
ConversationReactionOverlay.Action.UNSTAR_MESSAGE -> handleUnstarMessages(setOf(conversationMessage.messageRecord.id))
}
}
}

View File

@@ -431,6 +431,16 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
fun setMessageStarred(messageId: Long, starred: Boolean): Completable {
return setMessagesStarred(setOf(messageId), starred)
}
fun setMessagesStarred(messageIds: Set<Long>, starred: Boolean): Completable {
return Completable.fromAction {
SignalDatabase.messages.setStarred(messageIds, starred)
}.subscribeOn(Schedulers.io())
}
private fun applyUniversalExpireTimerIfNecessary(context: Context, recipient: Recipient, outgoingMessage: OutgoingMessage, threadId: Long): OutgoingMessage {
if (!outgoingMessage.isExpirationUpdate && outgoingMessage.expiresIn == 0L) {
val expireTimerVersion = RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId)

View File

@@ -381,6 +381,16 @@ class ConversationViewModel(
}
}
fun setMessageStarred(messageId: Long, starred: Boolean): Completable {
return setMessagesStarred(setOf(messageId), starred)
}
fun setMessagesStarred(messageIds: Set<Long>, starred: Boolean): Completable {
return repository
.setMessagesStarred(messageIds, starred)
.observeOn(AndroidSchedulers.mainThread())
}
fun updateThreadHeader() {
pagingController.onDataItemChanged(ConversationElementKey.threadHeader)
}

View File

@@ -44,7 +44,11 @@ fun V2ConversationItemMediaIncomingBinding.bridge(): V2ConversationItemMediaBind
alert = null,
footerSpace = null,
isIncoming = true,
footerPinned = conversationItemFooterPinned
footerPinned = conversationItemFooterPinned,
footerStarred = conversationItemFooterStarred,
starredSource = conversationItemStarredSource,
starredSourceWrapper = conversationItemStarredSourceWrapper,
starredSourceAvatar = conversationItemStarredSourceAvatar
)
return V2ConversationItemMediaBindingBridge(
@@ -75,7 +79,11 @@ fun V2ConversationItemMediaOutgoingBinding.bridge(): V2ConversationItemMediaBind
alert = conversationItemAlert,
footerSpace = footerEndPad,
isIncoming = false,
footerPinned = conversationItemFooterPinned
footerPinned = conversationItemFooterPinned,
footerStarred = conversationItemFooterStarred,
starredSource = null,
starredSourceWrapper = null,
starredSourceAvatar = null
)
return V2ConversationItemMediaBindingBridge(

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.conversation.v2.items
import org.signal.core.util.dp
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Projection
@@ -93,6 +94,9 @@ class V2ConversationItemShape(
nextMessage: MessageRecord?,
isGroupThread: Boolean
): Boolean {
if (conversationContext.displayMode is ConversationItemDisplayMode.Starred) {
return true
}
return isStartOfMessageCluster(currentMessage, previousMessage, isGroupThread) && isEndOfMessageCluster(currentMessage, nextMessage)
}

View File

@@ -43,7 +43,11 @@ data class V2ConversationItemTextOnlyBindingBridge(
val footerSpace: Space?,
val alert: AlertView?,
val isIncoming: Boolean,
val footerPinned: ImageView
val footerPinned: ImageView,
val footerStarred: ImageView,
val starredSource: TextView?,
val starredSourceWrapper: View?,
val starredSourceAvatar: AvatarImageView?
)
/**
@@ -66,7 +70,11 @@ fun V2ConversationItemTextOnlyIncomingBinding.bridge(): V2ConversationItemTextOn
alert = null,
footerSpace = footerEndPad,
isIncoming = true,
footerPinned = conversationItemFooterPinned
footerPinned = conversationItemFooterPinned,
footerStarred = conversationItemFooterStarred,
starredSource = conversationItemStarredSource,
starredSourceWrapper = conversationItemStarredSourceWrapper,
starredSourceAvatar = conversationItemStarredSourceAvatar
)
}
@@ -90,6 +98,10 @@ fun V2ConversationItemTextOnlyOutgoingBinding.bridge(): V2ConversationItemTextOn
alert = conversationItemAlert,
footerSpace = footerEndPad,
isIncoming = false,
footerPinned = conversationItemFooterPinned
footerPinned = conversationItemFooterPinned,
footerStarred = conversationItemFooterStarred,
starredSource = null,
starredSourceWrapper = null,
starredSourceAvatar = null
)
}

View File

@@ -97,7 +97,8 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
binding.footerExpiry,
binding.deliveryStatus,
binding.footerBackground,
binding.footerPinned
binding.footerPinned,
binding.footerStarred
)
override val reactionsView: View = binding.reactions
@@ -260,6 +261,8 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
presentDeliveryStatus()
presentFooterBackground()
presentFooterPinned()
presentFooterStarred()
presentStarredSource()
presentFooterExpiry()
presentFooterEndPadding()
presentAlert()
@@ -541,6 +544,27 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
pinned.visible = conversationMessage.messageRecord.pinnedUntil > 0
}
private fun presentFooterStarred() {
val starred = binding.footerStarred
starred.setColorFilter(themeDelegate.getFooterForegroundColor(conversationMessage), PorterDuff.Mode.SRC_IN)
starred.visible = conversationMessage.messageRecord.isStarred
}
private fun presentStarredSource() {
val wrapper = binding.starredSourceWrapper ?: return
val sourceView = binding.starredSource ?: return
if (conversationContext.displayMode is ConversationItemDisplayMode.Starred) {
val senderName = conversationMessage.messageRecord.fromRecipient.getShortDisplayName(context)
val chatName = conversationMessage.threadRecipient.getShortDisplayName(context)
sourceView.text = context.getString(R.string.StarredMessages__s_chevron_s, senderName, chatName)
wrapper.visible = true
binding.starredSourceAvatar?.setAvatar(conversationContext.requestManager, conversationMessage.messageRecord.fromRecipient, false)
} else {
wrapper.visible = false
}
}
private fun presentFooterEndPadding() {
binding.footerSpace?.visibility = if (isForcedFooter() || shape.isEndingShape) {
View.INVISIBLE
@@ -550,7 +574,8 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
}
private fun presentSender() {
if (conversationMessage.threadRecipient.isGroup) {
val isStarredMode = conversationContext.displayMode is ConversationItemDisplayMode.Starred
if (conversationMessage.threadRecipient.isGroup && !isStarredMode) {
presentSenderPhoto()
presentSenderBadge()
presentSenderNameWithLabel()
@@ -841,7 +866,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
}
private fun isForcedFooter(): Boolean {
return conversationMessage.messageRecord.isEditMessage || conversationMessage.messageRecord.expiresIn > 0L || conversationMessage.messageRecord.pinnedUntil > 0
return conversationMessage.messageRecord.isEditMessage || conversationMessage.messageRecord.expiresIn > 0L || conversationMessage.messageRecord.pinnedUntil > 0 || conversationMessage.messageRecord.isStarred
}
private inner class ReactionMeasureListener : V2ConversationItemLayout.OnMeasureListener {

View File

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

View File

@@ -48,6 +48,7 @@ public class DatabaseObserver {
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
private static final String KEY_CHAT_FOLDER = "ChatFolder";
private static final String KEY_STARRED_MESSAGES = "StarredMessages";
private final Executor executor;
@@ -71,6 +72,7 @@ public class DatabaseObserver {
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
private final Set<InAppPaymentObserver> inAppPaymentObservers;
private final Set<Observer> chatFolderObservers;
private final Set<Observer> starredMessageObservers;
public DatabaseObserver() {
this.executor = new SerialExecutor(SignalExecutors.BOUNDED);
@@ -94,6 +96,7 @@ public class DatabaseObserver {
this.callLinkObservers = new HashMap<>();
this.inAppPaymentObservers = new HashSet<>();
this.chatFolderObservers = new HashSet<>();
this.starredMessageObservers = new HashSet<>();
}
public void registerConversationListObserver(@NonNull Observer listener) {
@@ -213,6 +216,10 @@ public class DatabaseObserver {
executor.execute(() -> chatFolderObservers.add(observer));
}
public void registerStarredMessageObserver(@NonNull Observer observer) {
executor.execute(() -> starredMessageObservers.add(observer));
}
public void unregisterObserver(@NonNull Observer listener) {
executor.execute(() -> {
conversationListObservers.remove(listener);
@@ -231,6 +238,7 @@ public class DatabaseObserver {
callUpdateObservers.remove(listener);
unregisterMapped(callLinkObservers, listener);
chatFolderObservers.remove(listener);
starredMessageObservers.remove(listener);
});
}
@@ -399,6 +407,10 @@ public class DatabaseObserver {
runPostSuccessfulTransaction(KEY_CHAT_FOLDER, () -> notifySet(chatFolderObservers));
}
public void notifyStarredMessageObservers() {
runPostSuccessfulTransaction(KEY_STARRED_MESSAGES, () -> notifySet(starredMessageObservers));
}
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
executor.execute(runnable);

View File

@@ -227,6 +227,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val PINNED_AT = "pinned_at"
const val DELETED_BY = "deleted_by"
const val STORY_ARCHIVED = "story_archived"
const val STARRED = "starred"
const val QUOTE_NOT_PRESENT_ID = 0L
const val QUOTE_TARGET_MISSING_ID = -1L
@@ -299,7 +300,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$PINNING_MESSAGE_ID INTEGER DEFAULT 0,
$PINNED_AT INTEGER DEFAULT 0,
$DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
$STORY_ARCHIVED INTEGER DEFAULT 0
$STORY_ARCHIVED INTEGER DEFAULT 0,
$STARRED INTEGER DEFAULT 0
)
"""
@@ -334,7 +336,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"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)",
"CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)",
"CREATE INDEX IF NOT EXISTS message_story_archived_index ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0"
"CREATE INDEX IF NOT EXISTS message_story_archived_index ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0",
"CREATE INDEX IF NOT EXISTS message_starred_index ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0"
)
private val MMS_PROJECTION_BASE = arrayOf(
@@ -390,7 +393,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
VOTES_UNREAD,
VOTES_LAST_SEEN,
PINNED_UNTIL,
DELETED_BY
DELETED_BY,
STARRED
)
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE
@@ -2150,6 +2154,47 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun setStarred(messageId: Long, starred: Boolean) {
setStarred(setOf(messageId), starred)
}
fun setStarred(messageIds: Set<Long>, starred: Boolean) {
writableDatabase.withinTransaction { db ->
for (messageId in messageIds) {
db.update(TABLE_NAME)
.values(STARRED to if (starred) 1 else 0)
.where("$ID = ?", messageId)
.run()
}
}
val threadIds = messageIds.map { getThreadIdForMessage(it) }.toSet()
for (threadId in threadIds) {
notifyConversationListeners(threadId)
}
for (messageId in messageIds) {
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
AppDependencies.databaseObserver.notifyStarredMessageObservers()
}
fun getStarredMessages(threadId: Long? = null): List<MessageRecord> {
val where: String
val args: Array<String>?
if (threadId != null) {
where = "$STARRED > 0 AND $THREAD_ID = ? AND $LATEST_REVISION_ID IS NULL"
args = buildArgs(threadId)
} else {
where = "$STARRED > 0 AND $LATEST_REVISION_ID IS NULL"
args = null
}
return mmsReaderFor(queryMessages(where, args, reverse = true)).use { reader ->
reader.mapNotNull { it }
}.withAttachments()
}
fun getRecentPendingMessages(): MmsReader {
val now = System.currentTimeMillis()
val oneDayAgo = now.milliseconds - 1.days
@@ -2301,7 +2346,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
LINK_PREVIEWS to null,
SHARED_CONTACTS to null,
ORIGINAL_MESSAGE_ID to null,
LATEST_REVISION_ID to null
LATEST_REVISION_ID to null,
STARRED to 0
)
.where("$ID = ?", messageId)
.run()
@@ -2925,6 +2971,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.readToSingleInt(0)
contentValues.put(NOTIFIED, notified.toInt())
contentValues.put(STARRED, if (editedMessage.isStarred) 1 else 0)
} else if (MessageTypes.isPinnedMessageUpdate(type)) {
contentValues.put(NOTIFIED, 1)
}
@@ -3343,6 +3390,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues.put(ORIGINAL_MESSAGE_ID, editedMessage.getOriginalOrOwnMessageId().id)
contentValues.put(REVISION_NUMBER, editedMessage.revisionNumber + 1)
contentValues.put(EXPIRE_STARTED, editedMessage.expireStarted)
contentValues.put(STARRED, if (editedMessage.isStarred) 1 else 0)
} else {
contentValues.putNull(ORIGINAL_MESSAGE_ID)
}
@@ -6402,6 +6450,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val isRead = cursor.requireBoolean(READ)
val pinnedUntil = cursor.requireLong(PINNED_UNTIL)
val deletedBy = cursor.requireLongOrNull(DELETED_BY)?.let { RecipientId.from(it) }
val isStarred = cursor.requireBoolean(STARRED)
val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS)
val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) }
@@ -6497,7 +6546,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
isRead,
pinnedUntil,
deletedBy,
messageExtras
messageExtras,
isStarred
)
}

View File

@@ -16,6 +16,7 @@ object RxDatabaseObserver {
val conversationList: Flowable<Unit> by lazy { conversationListFlowable() }
val notificationProfiles: Flowable<Unit> by lazy { notificationProfilesFlowable() }
val chatFolders: Flowable<Unit> by lazy { chatFoldersFlowable() }
val starredMessages: Flowable<Unit> by lazy { starredMessagesFlowable() }
private fun conversationListFlowable(): Flowable<Unit> {
return databaseFlowable { listener ->
@@ -43,6 +44,12 @@ object RxDatabaseObserver {
}
}
private fun starredMessagesFlowable(): Flowable<Unit> {
return databaseFlowable { listener ->
AppDependencies.databaseObserver.registerStarredMessageObserver(listener)
}
}
private fun databaseFlowable(registerObserver: (RxObserver) -> Unit): Flowable<Unit> {
val flowable = Flowable.create(
{

View File

@@ -162,6 +162,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchiv
import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V309_GroupTerminatedColumnMigration
import org.thoughtcrime.securesms.database.helpers.migration.V310_AddStarredColumn
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -331,10 +332,11 @@ object SignalDatabaseMigrations {
306 to V306_AddRemoteDeletedColumn,
// 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future
308 to V308_AddBackRemoteDeletedColumn,
309 to V309_GroupTerminatedColumnMigration
309 to V309_GroupTerminatedColumnMigration,
310 to V310_AddStarredColumn
)
const val DATABASE_VERSION = 309
const val DATABASE_VERSION = 310
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds a column for tracking the starred status of a message.
*/
@Suppress("ClassName")
object V310_AddStarredColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE message ADD COLUMN starred INTEGER DEFAULT 0")
db.execSQL("CREATE INDEX IF NOT EXISTS message_starred_index ON message (starred) WHERE starred > 0")
}
}

View File

@@ -60,7 +60,8 @@ public class InMemoryMessageRecord extends MessageRecord {
0,
0,
null,
null);
null,
false);
}
@Override

View File

@@ -116,6 +116,7 @@ public abstract class MessageRecord extends DisplayRecord {
private final long pinnedUntil;
private final RecipientId deletedBy;
private final MessageExtras messageExtras;
private final boolean starred;
protected Boolean isJumboji = null;
@@ -138,7 +139,8 @@ public abstract class MessageRecord extends DisplayRecord {
int revisionNumber,
long pinnedUntil,
@Nullable RecipientId deletedBy,
@Nullable MessageExtras messageExtras)
@Nullable MessageExtras messageExtras,
boolean starred)
{
super(body, fromRecipient, toRecipient, dateSent, dateReceived,
threadId, deliveryStatus, hasDeliveryReceipt, type,
@@ -161,6 +163,7 @@ public abstract class MessageRecord extends DisplayRecord {
this.pinnedUntil = pinnedUntil;
this.deletedBy = deletedBy;
this.messageExtras = messageExtras;
this.starred = starred;
}
public abstract boolean isMms();
@@ -799,6 +802,10 @@ public abstract class MessageRecord extends DisplayRecord {
return deletedBy;
}
public boolean isStarred() {
return starred;
}
public boolean isPendingAdminDelete() {
return messageExtras != null &&
messageExtras.adminDeleteStatus != null &&

View File

@@ -121,12 +121,13 @@ public class MmsMessageRecord extends MessageRecord {
boolean isRead,
long pinnedUntil,
@Nullable RecipientId deletedBy,
@Nullable MessageExtras messageExtras)
@Nullable MessageExtras messageExtras,
boolean starred)
{
super(id, body, fromRecipient, fromDeviceId, toRecipient,
dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt,
mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt,
unidentified, reactions, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, deletedBy, messageExtras);
unidentified, reactions, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, deletedBy, messageExtras, starred);
this.slideDeck = slideDeck;
this.quote = quote;
@@ -334,12 +335,21 @@ public class MmsMessageRecord extends MessageRecord {
(parentStoryId == null || parentStoryId.isDirectReply());
}
public @NonNull MmsMessageRecord withIncomingType() {
long incomingType = (getType() & ~MessageTypes.BASE_TYPE_MASK) | MessageTypes.BASE_INBOX_TYPE;
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
incomingType, getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
public @NonNull MmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras());
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
public @NonNull MmsMessageRecord withoutQuote() {
@@ -347,7 +357,7 @@ public class MmsMessageRecord extends MessageRecord {
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras());
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
public @NonNull MmsMessageRecord withAttachments(@NonNull List<DatabaseAttachment> attachments) {
@@ -369,7 +379,7 @@ public class MmsMessageRecord extends MessageRecord {
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras());
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) {
@@ -377,7 +387,7 @@ public class MmsMessageRecord extends MessageRecord {
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras());
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
@@ -386,7 +396,7 @@ public class MmsMessageRecord extends MessageRecord {
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras());
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) {
@@ -394,7 +404,7 @@ public class MmsMessageRecord extends MessageRecord {
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras());
getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred());
}
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {

View File

@@ -11,6 +11,7 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
const val BETTER_SEARCH: String = "labs.better_search"
const val AUTO_LOWER_HAND: String = "labs.auto_lower_hand"
const val NEW_APNG_RENDERER: String = "labs.new_apng_renderer"
const val STARRED_MESSAGES: String = "labs.starred_messages"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -31,6 +32,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
var newApngRenderer by booleanValue(NEW_APNG_RENDERER, true).falseForExternalUsers()
var starredMessages by booleanValue(STARRED_MESSAGES, true).falseForExternalUsers()
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}

View File

@@ -107,6 +107,7 @@ interface MainToolbarCallback {
fun onCloseActionModeClick()
fun onSearchQueryUpdated(query: String)
fun onSearchFilterClick()
fun onStarredMessagesClick()
fun onNotificationProfileTooltipDismissed()
object Empty : MainToolbarCallback {
@@ -130,6 +131,7 @@ interface MainToolbarCallback {
override fun onCloseActionModeClick() = Unit
override fun onSearchQueryUpdated(query: String) = Unit
override fun onSearchFilterClick() = Unit
override fun onStarredMessagesClick() = Unit
override fun onNotificationProfileTooltipDismissed() = Unit
}
}
@@ -723,6 +725,20 @@ private fun ChatDropdownItems(state: MainToolbarState, callback: MainToolbarCall
)
}
if (SignalStore.labs.starredMessages) {
DropdownMenus.Item(
text = {
Text(
text = stringResource(R.string.text_secure_normal__starred_messages)
)
},
onClick = {
callback.onStarredMessagesClick()
onOptionSelected()
}
)
}
DropdownMenus.Item(
text = {
Text(

View File

@@ -0,0 +1,411 @@
package org.thoughtcrime.securesms.starred
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.doOnNextLayout
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
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.mediapreview.MediaIntentFactory
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.stickers.StickerLocator
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.viewModel
import java.util.Locale
import org.signal.core.ui.R as CoreUiR
class StarredMessagesActivity : PassphraseRequiredActivity() {
companion object {
private const val EXTRA_THREAD_ID = "thread_id"
const val NO_THREAD_ID = -1L
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, StarredMessagesActivity::class.java)
}
@JvmStatic
fun createIntent(context: Context, threadId: Long): Intent {
return Intent(context, StarredMessagesActivity::class.java).apply {
putExtra(EXTRA_THREAD_ID, threadId)
}
}
}
private val viewModel by viewModel {
val threadId = intent.getLongExtra(EXTRA_THREAD_ID, NO_THREAD_ID)
val effectiveThreadId = if (threadId == NO_THREAD_ID) null else threadId
StarredMessagesViewModel(effectiveThreadId)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
SignalTheme {
StarredMessagesScreen(
viewModel = viewModel,
onNavigateBack = { supportFinishAfterTransition() },
onNavigateToMessage = ::navigateToMessage
)
}
}
}
private fun navigateToMessage(messageRecord: MessageRecord) {
lifecycleScope.launch {
val (threadRecipient, startingPosition) = withContext(Dispatchers.IO) {
val position = SignalDatabase.messages.getMessagePositionInConversation(messageRecord.threadId, messageRecord.dateReceived)
val recipient = SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)
Pair(recipient, maxOf(0, position))
}
if (threadRecipient != null) {
val intent = ConversationIntents.createBuilderSync(this@StarredMessagesActivity, threadRecipient.id, messageRecord.threadId)
.withStartingPosition(startingPosition)
.build()
startActivity(intent)
finish()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StarredMessagesScreen(
viewModel: StarredMessagesViewModel,
onNavigateBack: () -> Unit,
onNavigateToMessage: (MessageRecord) -> Unit
) {
val messages by viewModel.getMessages().collectAsStateWithLifecycle(initialValue = emptyList())
val scope = rememberCoroutineScope()
val context = LocalContext.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
topBar = {
Scaffolds.DefaultTopAppBar(
title = stringResource(R.string.StarredMessagesActivity__starred_messages),
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
onNavigationClick = onNavigateBack,
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { padding ->
Box(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
StarredMessageList(
messages = messages,
onItemClick = onNavigateToMessage,
onQuoteClick = onNavigateToMessage,
onUnstarMessage = { messageId ->
scope.launch {
try {
viewModel.unstarMessage(messageId)
} catch (e: Exception) {
Toast.makeText(context, "Failed to unstar message", Toast.LENGTH_SHORT).show()
}
}
},
modifier = Modifier.fillMaxSize()
)
if (messages.isEmpty()) {
EmptyState(modifier = Modifier.fillMaxSize())
}
}
}
}
@SuppressLint("WrongThread")
@Composable
private fun StarredMessageList(
messages: List<ConversationMessage>,
onItemClick: (MessageRecord) -> Unit,
onQuoteClick: (MessageRecord) -> Unit,
onUnstarMessage: (Long) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val onItemClickState = rememberUpdatedState(onItemClick)
val onQuoteClickState = rememberUpdatedState(onQuoteClick)
val onUnstarMessageState = rememberUpdatedState(onUnstarMessage)
val adapter = remember {
@Suppress("DEPRECATION")
val colorizer = ColorizerV1()
ConversationAdapter(
context,
lifecycleOwner,
Glide.with(context),
Locale.getDefault(),
StarredMessageClickListener(
onItemClick = { onItemClickState.value(it) },
onQuoteClick = { onQuoteClickState.value(it) },
onUnstarMessage = { onUnstarMessageState.value(it) },
context = context
),
false,
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.Starred)
}
}
AndroidView(
factory = { ctx ->
FrameLayout(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val videoContainer = FrameLayout(ctx).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
addView(videoContainer)
val recyclerView = RecyclerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
clipToPadding = false
setPadding(0, 0, 0, (24 * resources.displayMetrics.density).toInt())
layoutManager = SmoothScrollingLinearLayoutManager(ctx, true)
this.adapter = adapter
itemAnimator = null
doOnNextLayout {
addItemDecoration(StickyHeaderDecoration(adapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
}
}
addView(recyclerView)
initializeGiphyMp4(lifecycleOwner.lifecycle, videoContainer, recyclerView)
}
},
update = {
adapter.submitList(messages)
},
modifier = modifier
)
}
private fun initializeGiphyMp4(lifecycle: Lifecycle, videoContainer: ViewGroup, list: RecyclerView) {
val context = list.context
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(context, lifecycle, videoContainer, maxPlayback)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(R.drawable.symbol_star_24),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.alpha(0.5f),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.StarredMessagesFragment__no_starred_messages),
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.StarredMessagesFragment__tap_and_hold_on_a_message_to_star_it),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.alpha(0.7f)
)
}
}
private class StarredMessageClickListener(
private val onItemClick: (MessageRecord) -> Unit,
private val onQuoteClick: (MessageRecord) -> Unit,
private val onUnstarMessage: (Long) -> Unit,
private val context: Context
) : ConversationAdapter.ItemClickListener {
override fun onItemClick(item: MultiselectPart) {
onItemClick(item.getMessageRecord())
}
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
val messageRecord = item.getMessageRecord()
val items = mutableListOf<ActionItem>()
items.add(
ActionItem(R.drawable.symbol_star_outline_24, context.getString(R.string.conversation_selection__menu_unstar)) {
onUnstarMessage(messageRecord.id)
}
)
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.show(items)
}
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
onQuoteClick(messageRecord)
}
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) = Unit
override fun onStickerClicked(stickerLocator: StickerLocator) = Unit
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit
override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit
override fun onAddToContactsClicked(contact: Contact) = Unit
override fun onMessageSharedContactClicked(choices: MutableList<Recipient>) = Unit
override fun onInviteSharedContactClicked(choices: MutableList<Recipient>) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) = Unit
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
override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) = Unit
override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) = Unit
override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) = Unit
override fun onVoiceNotePause(uri: Uri) = Unit
override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) = Unit
override fun onVoiceNoteSeekTo(uri: Uri, position: Double) = Unit
override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) = Unit
override fun onPlayInlineContent(conversationMessage: ConversationMessage?) = Unit
override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) = Unit
override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) = Unit
override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit
override fun onChangeProfileNameUpdateContact(recipient: Recipient) = Unit
override fun onBlockJoinRequest(recipient: Recipient) = Unit
override fun onInviteToSignalClicked() = Unit
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
override fun onUrlClicked(url: String): Boolean = false
override fun onGiftBadgeRevealed(messageRecord: MessageRecord) = Unit
override fun goToMediaPreview(parent: ConversationItem, sharedElement: View, args: MediaIntentFactory.MediaPreviewArgs) = Unit
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
override fun onPaymentTombstoneClicked() = Unit
override fun onDisplayMediaNoLongerAvailableSheet() = Unit
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
override fun onUpdateSignalClicked() = Unit
override fun onViewPollClicked(messageId: Long) = Unit
override fun onViewPinnedMessage(messageId: Long) = Unit
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.starred
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
class StarredMessagesViewModel(
private val threadId: Long?
) : ViewModel() {
fun getMessages(): Flow<List<ConversationMessage>> {
val trigger = if (threadId != null) {
RxDatabaseObserver.conversation(threadId)
} else {
RxDatabaseObserver.starredMessages
}
return trigger.toObservable().asFlow()
.map {
val messages = SignalDatabase.messages.getStarredMessages(threadId)
messages.map { record ->
val incomingRecord = if (record is MmsMessageRecord && record.isOutgoing) {
record.withIncomingType()
} else {
record
}
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId) ?: Recipient.UNKNOWN
ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(
AppDependencies.application,
incomingRecord,
threadRecipient
)
}
}
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
suspend fun unstarMessage(messageId: Long) {
withContext(Dispatchers.IO) {
SignalDatabase.messages.setStarred(messageId, false)
}
}
class Factory(
private val threadId: Long?
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return StarredMessagesViewModel(threadId) as T
}
}
}