diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt index ac21029380..9dbcc6364b 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt @@ -122,7 +122,8 @@ class ConversationElementGenerator { false, 0, null, - null + null, + false ) val conversationMessage = ConversationMessageFactory.createWithUnresolvedData( diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7c27e7a31..a106d2e62c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -675,6 +675,13 @@ android:theme="@style/TextSecure.DarkNoActionBar" android:windowSoftInputMode="stateHidden" /> + + 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt index 1f378e19aa..a326d5e4ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt index fc4d63c10c..46aad914ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt @@ -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)) } + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt index d0ba8be66c..e6803d9512 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt index 726373e6f6..fefe9dbec8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt @@ -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 ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 4b54231611..65db87dd87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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 -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index ffcbd14c63..f4e80f0e04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 51cbdbee02..6cd5730f7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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 previous, @NonNull Optional 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 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 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 previous, @NonNull Optional 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt index feae8da7ce..db85547787 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 6a8ae2ef38..8b5ce13701 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index 26f7c86d18..ab4da02ad7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a25a4f0571..0c36ea8761 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -1955,6 +1955,22 @@ class ConversationFragment : ) } + private fun handleStarMessages(messageIds: Set) { + disposables += viewModel + .setMessagesStarred(messageIds, true) + .subscribeBy( + onError = { Log.w(TAG, "Error starring message!", it) } + ) + } + + private fun handleUnstarMessages(messageIds: Set) { + 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)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index d855155b6f..cd39910ca6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -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, 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 8dd32070ce..7ccd7390b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -381,6 +381,16 @@ class ConversationViewModel( } } + fun setMessageStarred(messageId: Long, starred: Boolean): Completable { + return setMessagesStarred(setOf(messageId), starred) + } + + fun setMessagesStarred(messageIds: Set, starred: Boolean): Completable { + return repository + .setMessagesStarred(messageIds, starred) + .observeOn(AndroidSchedulers.mainThread()) + } + fun updateThreadHeader() { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt index 3ec1ef6eef..c1b06dc86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt index d9f4087f57..98b558209a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt index 6cd156741f..fcfbb3399b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt index f682517bca..fb5075db60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt @@ -97,7 +97,8 @@ open class V2ConversationItemTextOnlyViewHolder>( 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>( presentDeliveryStatus() presentFooterBackground() presentFooterPinned() + presentFooterStarred() + presentStarredSource() presentFooterExpiry() presentFooterEndPadding() presentAlert() @@ -541,6 +544,27 @@ open class V2ConversationItemTextOnlyViewHolder>( 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>( } 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>( } 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt index 4433b836c0..95e9e1d5ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt @@ -36,7 +36,8 @@ class V2FooterPositionDelegate private constructor( binding.deliveryStatus, binding.footerExpiry, binding.footerSpace, - binding.footerPinned + binding.footerPinned, + binding.footerStarred ), binding.bodyWrapper, binding.body, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index 05f8a29424..1ae0684ef0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -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> callLinkObservers; private final Set inAppPaymentObservers; private final Set chatFolderObservers; + private final Set 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 11c12047fc..905f54bb11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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 = 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, 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 { + val where: String + val args: Array? + + 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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt index ef829b33ca..80b63bc13d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt @@ -16,6 +16,7 @@ object RxDatabaseObserver { val conversationList: Flowable by lazy { conversationListFlowable() } val notificationProfiles: Flowable by lazy { notificationProfilesFlowable() } val chatFolders: Flowable by lazy { chatFoldersFlowable() } + val starredMessages: Flowable by lazy { starredMessagesFlowable() } private fun conversationListFlowable(): Flowable { return databaseFlowable { listener -> @@ -43,6 +44,12 @@ object RxDatabaseObserver { } } + private fun starredMessagesFlowable(): Flowable { + return databaseFlowable { listener -> + AppDependencies.databaseObserver.registerStarredMessageObserver(listener) + } + } + private fun databaseFlowable(registerObserver: (RxObserver) -> Unit): Flowable { val flowable = Flowable.create( { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index c21e8b5724..ff5570e062 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V310_AddStarredColumn.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V310_AddStarredColumn.kt new file mode 100644 index 0000000000..b97b365172 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V310_AddStarredColumn.kt @@ -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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index 539c61c1f1..297a5d701e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -60,7 +60,8 @@ public class InMemoryMessageRecord extends MessageRecord { 0, 0, null, - null); + null, + false); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index dfe8645505..9595718c30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -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 && diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 8c2b1069f2..37972e42fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -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 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 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 updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt index 473b72d80d..d2b4f41020 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt @@ -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.falseForExternalUsers(): SignalStoreValueDelegate { return this.map { actualValue -> RemoteConfig.internalUser && actualValue } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index 7ed6306ff2..968093d286 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt new file mode 100644 index 0000000000..53d4fc57f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt @@ -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, + 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() + + 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) = Unit + override fun onInviteSharedContactClicked(choices: MutableList) = 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) = Unit + override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) = 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesViewModel.kt new file mode 100644 index 0000000000..3ee80c1b12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesViewModel.kt @@ -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> { + 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 create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return StarredMessagesViewModel(threadId) as T + } + } +} diff --git a/app/src/main/res/drawable/symbol_star_24.xml b/app/src/main/res/drawable/symbol_star_24.xml new file mode 100644 index 0000000000..7b2cc52cc5 --- /dev/null +++ b/app/src/main/res/drawable/symbol_star_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/symbol_star_filled_12.xml b/app/src/main/res/drawable/symbol_star_filled_12.xml new file mode 100644 index 0000000000..a5c13dc709 --- /dev/null +++ b/app/src/main/res/drawable/symbol_star_filled_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_star_outline_24.xml b/app/src/main/res/drawable/symbol_star_outline_24.xml new file mode 100644 index 0000000000..8ae6ce19be --- /dev/null +++ b/app/src/main/res/drawable/symbol_star_outline_24.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/layout/conversation_item_footer.xml b/app/src/main/res/layout/conversation_item_footer.xml index ce621b2c2c..26f33dfff7 100644 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ b/app/src/main/res/layout/conversation_item_footer.xml @@ -68,11 +68,22 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/footer_date" + app:layout_constraintEnd_toStartOf="@id/footer_starred" android:src="@drawable/symbol_pin_filled_12" android:visibility="gone" android:layout_marginEnd="4dp" /> + + + diff --git a/app/src/main/res/layout/conversation_item_received_multimedia.xml b/app/src/main/res/layout/conversation_item_received_multimedia.xml index 224bb80954..a2dd05c467 100644 --- a/app/src/main/res/layout/conversation_item_received_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_received_multimedia.xml @@ -65,10 +65,21 @@ android:visibility="gone" app:badge_size="small" /> + + + + + + + + + + + diff --git a/app/src/main/res/layout/v2_conversation_item_media_incoming.xml b/app/src/main/res/layout/v2_conversation_item_media_incoming.xml index e82fe520d7..3d24dcdd80 100644 --- a/app/src/main/res/layout/v2_conversation_item_media_incoming.xml +++ b/app/src/main/res/layout/v2_conversation_item_media_incoming.xml @@ -52,6 +52,37 @@ app:layout_constraintTop_toTopOf="@id/contact_photo" tools:visibility="visible" /> + + + + + + + + + @@ -170,6 +201,18 @@ android:src="@drawable/symbol_pin_filled_12" android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date" + app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_starred" + app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date" /> + + diff --git a/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml b/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml index 0a05bb440c..951c37c975 100644 --- a/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml +++ b/app/src/main/res/layout/v2_conversation_item_media_outgoing.xml @@ -148,10 +148,22 @@ 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" + app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_starred" android:visibility="gone" android:src="@drawable/symbol_pin_filled_12" /> + + + + + + + + + + + @@ -129,6 +160,18 @@ android:src="@drawable/symbol_pin_filled_12" android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date" + app:layout_constraintEnd_toStartOf="@id/conversation_item_footer_starred" + app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date" /> + + diff --git a/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml b/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml index 8318580634..5419b2ab54 100644 --- a/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml +++ b/app/src/main/res/layout/v2_conversation_item_text_only_outgoing.xml @@ -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,conversation_item_footer_pinned" /> + app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date,conversation_item_footer_pinned,conversation_item_footer_starred" /> + app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date,conversation_item_footer_pinned,conversation_item_footer_starred" /> + + Replies + + + Starred messages + + No starred messages + + Tap and hold on a message to star it. + + %1$s \u203A %2$s + Establishing Signal call @@ -4593,6 +4603,10 @@ Copy Delete + + Star (Labs) + + Unstar (Labs) Forward @@ -4705,6 +4719,8 @@ New group Settings + + Starred messages (Labs) Lock Mark all read Invite friends @@ -6017,6 +6033,8 @@ Search Disappearing messages Sounds & notifications + + Starred messages Internal details Phone contact info View safety number diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 75d300f286..7c38132ad4 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -210,7 +210,8 @@ object FakeMessageRecords { false, 0, deletedBy, - null + null, + false ) } }