diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 5ec9882bb..502fff063 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -46,7 +46,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, boolean hasWallpaper, boolean isMessageRequestAccepted, boolean canPlayInline, - @NonNull Colorizer colorizer); + @NonNull Colorizer colorizer, + boolean isCondensedMode); @NonNull ConversationMessage getConversationMessage(); @@ -61,12 +62,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, } default void updateSelectedState() { - // Intentionall Blank. + // Intentionally Blank. } interface EventListener { void onQuoteClicked(MmsMessageRecord messageRecord); void onLinkPreviewClicked(@NonNull LinkPreview linkPreview); + void onQuotedIndicatorClicked(@NonNull MessageRecord messageRecord); void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms); void onStickerClicked(@NonNull StickerLocator stickerLocator); void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java index 742dfb2c7..439b4d96b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -10,6 +10,7 @@ import android.widget.ImageView; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.Px; import androidx.annotation.UiThread; import org.thoughtcrime.securesms.R; @@ -35,6 +36,7 @@ public class ConversationItemThumbnail extends FrameLayout { private int[] normalBounds; private int[] gifBounds; private int minimumThumbnailWidth; + private int maximumThumbnailHeight; public ConversationItemThumbnail(Context context) { super(context); @@ -83,7 +85,8 @@ public class ConversationItemThumbnail extends FrameLayout { Integer.MAX_VALUE }; - minimumThumbnailWidth = -1; + minimumThumbnailWidth = -1; + maximumThumbnailHeight = -1; } @SuppressWarnings("SuspiciousNameCombination") @@ -143,11 +146,16 @@ public class ConversationItemThumbnail extends FrameLayout { cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); } - public void setMinimumThumbnailWidth(int width) { + public void setMinimumThumbnailWidth(@Px int width) { minimumThumbnailWidth = width; thumbnail.setMinimumThumbnailWidth(width); } + public void setMaximumThumbnailHeight(@Px int height) { + maximumThumbnailHeight = height; + thumbnail.setMaximumThumbnailHeight(height); + } + public void setBorderless(boolean borderless) { this.borderless = borderless; } @@ -170,6 +178,10 @@ public class ConversationItemThumbnail extends FrameLayout { if (minimumThumbnailWidth != -1) { thumbnail.setMinimumThumbnailWidth(minimumThumbnailWidth); } + + if (maximumThumbnailHeight != -1) { + thumbnail.setMaximumThumbnailHeight(maximumThumbnailHeight); + } } thumbnail.setVisibility(VISIBLE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java index a3c68ced1..242bdde2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java @@ -128,6 +128,10 @@ public class LinkPreviewView extends FrameLayout { } public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) { + setLinkPreview(glideRequests, linkPreview, showThumbnail, true); + } + + public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail, boolean showDescription) { spinner.setVisibility(GONE); noPreview.setVisibility(GONE); @@ -138,7 +142,7 @@ public class LinkPreviewView extends FrameLayout { title.setVisibility(GONE); } - if (!Util.isEmpty(linkPreview.getDescription())) { + if (showDescription && !Util.isEmpty(linkPreview.getDescription())) { description.setText(linkPreview.getDescription()); description.setVisibility(VISIBLE); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index 62fdf5a25..f607eed6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -14,6 +14,7 @@ import android.widget.FrameLayout; import android.widget.ImageView; import androidx.annotation.NonNull; +import androidx.annotation.Px; import androidx.annotation.UiThread; import com.bumptech.glide.RequestBuilder; @@ -155,11 +156,16 @@ public class ThumbnailView extends FrameLayout { captionIcon.setScaleY(captionIconScale); } - public void setMinimumThumbnailWidth(int width) { + public void setMinimumThumbnailWidth(@Px int width) { bounds[MIN_WIDTH] = width; invalidate(); } + public void setMaximumThumbnailHeight(@Px int height) { + bounds[MAX_HEIGHT] = height; + invalidate(); + } + @SuppressWarnings("SuspiciousNameCombination") private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) { int dimensFilledCount = getNonZeroCount(dimens); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index a4363cba5..35d057488 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat; import androidx.core.view.ViewKt; import androidx.core.widget.TextViewCompat; +import org.signal.core.util.StringUtil; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; @@ -151,10 +152,10 @@ public class EmojiTextView extends AppCompatTextView { // Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688) // We ellipsize them ourselves by manually truncating the appropriate section. if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) { - if (maxLength > 0) { - ellipsizeAnyTextForMaxLength(); - } else if (getMaxLines() > 0) { + if (getMaxLines() > 0) { ellipsizeEmojiTextForMaxLines(); + } else if (maxLength > 0) { + ellipsizeAnyTextForMaxLength(); } } @@ -308,11 +309,17 @@ public class EmojiTextView extends AppCompatTextView { int lineCount = getLineCount(); if (lineCount > maxLines) { - int overflowStart = getLayout().getLineStart(maxLines - 1); + int overflowStart = getLayout().getLineStart(maxLines - 1); + + if (maxLength > 0 && overflowStart > maxLength) { + ellipsizeAnyTextForMaxLength(); + return; + } + int overflowEnd = getLayout().getLineEnd(maxLines - 1); CharSequence overflow = getText().subSequence(overflowStart, overflowEnd); float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f; - CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END); + CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END)); SpannableStringBuilder newContent = new SpannableStringBuilder(); newContent.append(getText().subSequence(0, overflowStart)) @@ -323,6 +330,8 @@ public class EmojiTextView extends AppCompatTextView { CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji); super.setText(emojified, BufferType.SPANNABLE); + } else if (maxLength > 0) { + ellipsizeAnyTextForMaxLength(); } }; 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 d5e1ba745..dd50302b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -123,8 +123,9 @@ public class ConversationAdapter private ConversationMessage inlineContent; private Colorizer colorizer; private boolean isTypingViewEnabled; + private boolean condensedMode; - ConversationAdapter(@NonNull Context context, + public ConversationAdapter(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner, @NonNull GlideRequests glideRequests, @NonNull Locale locale, @@ -177,9 +178,9 @@ public class ConversationAdapter } else if (messageRecord.isUpdate()) { return MESSAGE_TYPE_UPDATE; } else if (messageRecord.isOutgoing()) { - return MessageRecordUtil.isTextOnly(messageRecord, context) ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA; + return MessageRecordUtil.isTextOnly(messageRecord, context) && !conversationMessage.hasBeenQuoted() ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA; } else { - return MessageRecordUtil.isTextOnly(messageRecord, context) ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA; + return MessageRecordUtil.isTextOnly(messageRecord, context) && !conversationMessage.hasBeenQuoted() ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA; } } @@ -259,6 +260,11 @@ public class ConversationAdapter } } + public void setCondensedMode(boolean condensedMode) { + this.condensedMode = condensedMode; + notifyDataSetChanged(); + } + @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { switch (getItemViewType(position)) { @@ -284,10 +290,11 @@ public class ConversationAdapter recipient, searchQuery, conversationMessage == recordToPulse, - hasWallpaper, + hasWallpaper && !condensedMode, isMessageRequestAccepted, conversationMessage == inlineContent, - colorizer); + colorizer, + condensedMode); if (conversationMessage == recordToPulse) { recordToPulse = null; @@ -776,7 +783,7 @@ public class ConversationAdapter } } - interface ItemClickListener extends BindableConversationItem.EventListener { + public interface ItemClickListener extends BindableConversationItem.EventListener { void onItemClick(MultiselectPart item); void onItemLongClick(View itemView, MultiselectPart item); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 2536eb4d2..a6e4cc93e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -25,7 +25,6 @@ import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.graphics.Bitmap; -import android.graphics.Color; import android.graphics.Rect; import android.net.Uri; import android.os.AsyncTask; @@ -49,7 +48,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.WindowDecorActionBar; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; @@ -104,6 +102,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardBottomSheet; import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment; import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs; +import org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet; import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.database.MessageDatabase; @@ -112,6 +111,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; @@ -201,7 +201,7 @@ import java.util.concurrent.ExecutionException; import kotlin.Unit; @SuppressLint("StaticFieldLeak") -public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback { +public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback, MessageQuotesBottomSheet.Callback { private static final String TAG = Log.tag(ConversationFragment.class); private static final int SCROLL_ANIMATION_THRESHOLD = 50; @@ -1426,6 +1426,22 @@ public class ConversationFragment extends LoggingFragment implements Multiselect return true; } + @Override + public @NonNull ItemClickListener getConversationAdapterListener() { + return selectionClickListener; + } + + @Override + public void jumpToMessage(@NonNull MessageRecord messageRecord) { + SimpleTask.run(getLifecycle(), () -> { + return SignalDatabase.mmsSms().getMessagePositionInConversation(threadId, + messageRecord.getDateReceived(), + messageRecord.isOutgoing() ? Recipient.self().getId() : messageRecord.getRecipient().getId()); + }, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), () -> { + Toast.makeText(getContext(), R.string.ConversationFragment_failed_to_open_message, Toast.LENGTH_SHORT).show(); + })); + } + public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner { int getSendButtonTint(); boolean isKeyboardOpen(); @@ -1704,6 +1720,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } } + @Override + public void onQuotedIndicatorClicked(@NonNull MessageRecord messageRecord) { + if (getContext() != null && getActivity() != null) { + MessageQuotesBottomSheet.show( + getChildFragmentManager(), + new MessageId(messageRecord.getId(), messageRecord.isMms()), + recipient.getId() + ); + } + } + @Override public void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms) { if (getContext() != null && getActivity() != null) { 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 c88028642..e100ca17a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -173,6 +173,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo public static final float LONG_PRESS_SCALE_FACTOR = 0.95f; private static final int SHRINK_BUBBLE_DELAY_MILLIS = 100; private static final long MAX_CLUSTERING_TIME_DIFF = TimeUnit.MINUTES.toMillis(3); + private static final int CONDENSED_MODE_MAX_LINES = 3; private ConversationMessage conversationMessage; private MessageRecord messageRecord; @@ -182,6 +183,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private LiveRecipient recipient; private GlideRequests glideRequests; private ValueAnimator pulseOutlinerAlphaAnimator; + private Optional previousMessage; + + /** + * Whether or not we're rendering this item in a constrained space. + * Today this is only {@link org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet}. + */ + private boolean isCondensedMode; protected ConversationItemBodyBubble bodyBubble; protected View reply; @@ -199,6 +207,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo protected BadgeImageView badgeImageView; private View storyReactionLabelWrapper; private TextView storyReactionLabel; + protected View quotedIndicator; private @NonNull Set batchSelected = new HashSet<>(); private @NonNull Outliner outliner = new Outliner(); @@ -228,6 +237,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); + private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener(); private final UrlClickListener urlClickListener = new UrlClickListener(); private final Rect thumbnailMaskingRect = new Rect(); private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener(); @@ -259,6 +269,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo reactionsView.animate() .scaleX(LONG_PRESS_SCALE_FACTOR) .scaleY(LONG_PRESS_SCALE_FACTOR); + + if (quotedIndicator != null) { + quotedIndicator.animate() + .scaleX(LONG_PRESS_SCALE_FACTOR) + .scaleY(LONG_PRESS_SCALE_FACTOR); + } } }; @@ -307,6 +323,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder); this.storyReactionLabel = findViewById(R.id.story_reacted_label); this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub)); + this.quotedIndicator = findViewById(R.id.quoted_indicator); setOnClickListener(new ClickListener(null)); @@ -329,7 +346,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo boolean hasWallpaper, boolean isMessageRequestAccepted, boolean allowedToPlayInline, - @NonNull Colorizer colorizer) + @NonNull Colorizer colorizer, + boolean isCondensedMode) { if (this.recipient != null) this.recipient.removeForeverObserver(this); if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this); @@ -350,6 +368,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo this.canPlayContent = false; this.mediaItem = null; this.colorizer = colorizer; + this.isCondensedMode = isCondensedMode; + this.previousMessage = previousMessageRecord; this.recipient.observeForever(this); this.conversationRecipient.observeForever(this); @@ -370,6 +390,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo setReactions(messageRecord); setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper); setStoryReactionLabel(messageRecord); + setHasBeenQuoted(conversationMessage); if (audioViewStub.resolved()) { audioViewStub.get().setOnLongClickListener(passthroughClickListener); @@ -388,6 +409,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo @Override public boolean dispatchTouchEvent(MotionEvent ev) { + if (isCondensedMode) return super.dispatchTouchEvent(ev); + switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: getHandler().postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS); @@ -401,6 +424,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo reactionsView.animate() .scaleX(1.0f) .scaleY(1.0f); + + if (quotedIndicator != null) { + quotedIndicator.animate() + .scaleX(1.0f) + .scaleY(1.0f); + } break; } @@ -864,6 +893,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + /** + * Whether or not we want to condense the actual content of the bubble. e.g. shorten image height, text content, etc. + * Today, we only want to do this for the first message when we're in condensed mode. + */ + private boolean isContentCondensed() { + return isCondensedMode && !previousMessage.isPresent(); + } + private boolean isStoryReaction(MessageRecord messageRecord) { return MessageRecordUtil.isStoryReaction(messageRecord); } @@ -901,11 +938,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } private boolean hasExtraText(MessageRecord messageRecord) { - return MessageRecordUtil.hasExtraText(messageRecord); + return MessageRecordUtil.hasExtraText(messageRecord) || isContentCondensed(); } private boolean hasQuote(MessageRecord messageRecord) { - return MessageRecordUtil.hasQuote(messageRecord); + return MessageRecordUtil.hasQuote(messageRecord) && (!isCondensedMode || !previousMessage.isPresent()); } private boolean hasSharedContact(MessageRecord messageRecord) { @@ -917,7 +954,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } private boolean hasBigImageLinkPreview(MessageRecord messageRecord) { - return MessageRecordUtil.hasBigImageLinkPreview(messageRecord, context); + return MessageRecordUtil.hasBigImageLinkPreview(messageRecord, context) && !isContentCondensed(); } private boolean isViewOnceMessage(MessageRecord messageRecord) { @@ -971,6 +1008,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, ThemeUtil.isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20)); } + if (isContentCondensed()) { + bodyText.setMaxLines(CONDENSED_MODE_MAX_LINES); + } else { + bodyText.setMaxLines(Integer.MAX_VALUE); + } + bodyText.setText(StringUtil.trim(styledText)); bodyText.setVisibility(View.VISIBLE); @@ -1063,6 +1106,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (hasBigImageLinkPreview(messageRecord)) { mediaThumbnailStub.require().setVisibility(VISIBLE); mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content)); + mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(R.dimen.media_bubble_max_height)); mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false); mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener()); mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener); @@ -1077,7 +1121,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); ViewUtil.setTopMargin(linkPreviewStub.get(), 0); } else { - linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true); + linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true, !isContentCondensed()); linkPreviewStub.get().setDownloadClickedListener(downloadClickListener); setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, false); ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -1180,11 +1224,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); - if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE); + if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE); List thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides(); mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo - : R.dimen.media_bubble_min_width_with_content)); + : R.dimen.media_bubble_min_width_with_content)); + mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(isCondensedMode ? R.dimen.media_bubble_max_height_condensed + : R.dimen.media_bubble_max_height)); mediaThumbnailStub.require().setImageResource(glideRequests, thumbnailSlides, showControls, @@ -1453,7 +1499,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private void setQuote(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional next, boolean isGroupThread, @NonNull ChatColors chatColors) { boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread); - if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) { + if (hasQuote(messageRecord)) { if (quoteView == null) { throw new AssertionError(); } @@ -1622,6 +1668,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private void setHasBeenQuoted(@NonNull ConversationMessage message) { + if (message.hasBeenQuoted() && quotedIndicator != null) { + quotedIndicator.setVisibility(VISIBLE); + quotedIndicator.setOnClickListener(quotedIndicatorClickListener); + } else if (quotedIndicator != null) { + quotedIndicator.setVisibility(GONE); + quotedIndicator.setOnClickListener(null); + } + } + private boolean forceFooter(@NonNull MessageRecord messageRecord) { return hasAudio(messageRecord); } @@ -2169,6 +2225,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private class QuotedIndicatorClickListener implements View.OnClickListener { + public void onClick(final View view) { + if (eventListener != null && batchSelected.isEmpty() && conversationMessage.hasBeenQuoted()) { + eventListener.onQuotedIndicatorClicked((messageRecord)); + } else { + passthroughClickListener.onClick(view); + } + } + } + private class AttachmentDownloadClickListener implements SlidesClickedListener { @Override public void onClick(View v, final List slides) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index d08d86a0a..6dbb5b1d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -32,16 +32,23 @@ public class ConversationMessage { @Nullable private final SpannableString body; @NonNull private final MultiselectCollection multiselectCollection; @NonNull private final MessageStyler.Result styleResult; + private final int quotedCount; private ConversationMessage(@NonNull MessageRecord messageRecord) { - this(messageRecord, null, null); + this(messageRecord, null, null, 0); + } + + private ConversationMessage(@NonNull MessageRecord messageRecord, int quotedCount) { + this(messageRecord, null, null, quotedCount); } private ConversationMessage(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, - @Nullable List mentions) + @Nullable List mentions, + int quotedCount) { this.messageRecord = messageRecord; + this.quotedCount = quotedCount; this.mentions = mentions != null ? mentions : Collections.emptyList(); if (body != null) { @@ -77,6 +84,14 @@ public class ConversationMessage { return multiselectCollection; } + public boolean hasBeenQuoted() { + return quotedCount > 0; + } + + public int getQuoteCount() { + return quotedCount; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -119,8 +134,8 @@ public class ConversationMessage { * heavy work performed as the message is assumed to not have any mentions. */ @AnyThread - public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord) { - return new ConversationMessage(messageRecord); + public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, int quotedCount) { + return new ConversationMessage(messageRecord, quotedCount); } /** @@ -128,15 +143,16 @@ public class ConversationMessage { * list of actual mentions. No database or heavy work performed as the body and mentions are assumed to be * fully updated with display names. * - * @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names. - * @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body. + * @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names. + * @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body. + * @param quotedCount The number of times a message has been quoted */ @AnyThread - public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List mentions) { + public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List mentions, int quotedCount) { if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { - return new ConversationMessage(messageRecord, body, mentions); + return new ConversationMessage(messageRecord, body, mentions, quotedCount); } - return new ConversationMessage(messageRecord, body, null); + return new ConversationMessage(messageRecord, body, null, quotedCount); } /** @@ -147,11 +163,13 @@ public class ConversationMessage { */ @WorkerThread public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @Nullable List mentions) { + int quotedCount = SignalDatabase.mmsSms().getQuotedCount(messageRecord); + if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions); - return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions()); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), quotedCount); } - return createWithResolvedData(messageRecord); + return createWithResolvedData(messageRecord, quotedCount); } /** @@ -171,14 +189,33 @@ public class ConversationMessage { */ @WorkerThread public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { + int quotedCount = SignalDatabase.mmsSms().getQuotedCount(messageRecord); + if (messageRecord.isMms()) { List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); if (!mentions.isEmpty()) { MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions); - return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions()); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), quotedCount); } } - return createWithResolvedData(messageRecord, body, null); + return createWithResolvedData(messageRecord, body, null, quotedCount); + } + + /** + * Creates a {@link ConversationMessage} wrapping the provided MessageRecord and body, and will query for potential mentions. If mentions + * are found, the body of the provided message will be updated and modified to match actual mentions. This will perform + * database operations to query for mentions and then to resolve mentions to display names. + */ + @WorkerThread + public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body, int quotedCount) { + if (messageRecord.isMms()) { + List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); + if (!mentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), quotedCount); + } + } + return createWithResolvedData(messageRecord, body, null, quotedCount); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java index 49fd8f5d1..d33d69c32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java @@ -25,6 +25,7 @@ final class ConversationSwipeAnimationHelper { private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10)); private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8)); private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX); + private static final Interpolator QUOTED_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(1f, 0f, 3f); private ConversationSwipeAnimationHelper() { } @@ -34,6 +35,7 @@ final class ConversationSwipeAnimationHelper { updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign); updateReactionsTransition(conversationItem.reactionsView, dx, sign); + updateQuotedIndicatorTransition(conversationItem.quotedIndicator, dx, progress, sign); updateReplyIconTransition(conversationItem.reply, dx, progress, sign); updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign); updateContactPhotoHolderTransition(conversationItem.badgeImageView, progress, sign); @@ -51,6 +53,13 @@ final class ConversationSwipeAnimationHelper { reactionsContainer.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); } + private static void updateQuotedIndicatorTransition(@Nullable View quotedIndicator, float dx, float progress, float sign) { + if (quotedIndicator != null) { + quotedIndicator.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + quotedIndicator.setAlpha(QUOTED_ALPHA_INTERPOLATOR.getInterpolation(progress) * sign); + } + } + private static void updateReplyIconTransition(@NonNull View replyIcon, float dx, float progress, float sign) { if (progress > 0.05f) { replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 52456d304..ee0e7cff6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -130,7 +130,8 @@ public final class ConversationUpdateItem extends FrameLayout boolean hasWallpaper, boolean isMessageRequestAccepted, boolean allowedToPlayInline, - @NonNull Colorizer colorizer) + @NonNull Colorizer colorizer, + boolean isCondensedMode) { this.batchSelected = batchSelected; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index b54b2333b..59a5c7880 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -13,12 +13,9 @@ import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; -import com.annimon.stream.Stream; - import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import org.signal.core.util.MapUtil; import org.signal.core.util.logging.Log; import org.signal.paging.ObservablePagedData; import org.signal.paging.PagedData; @@ -28,15 +25,12 @@ import org.signal.paging.ProxyPagingController; import org.signal.libsignal.protocol.util.Pair; import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository; import org.thoughtcrime.securesms.conversation.colors.ChatColors; -import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; +import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper; import org.thoughtcrime.securesms.conversation.colors.NameColor; import org.thoughtcrime.securesms.database.DatabaseObserver; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaRepository; import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile; @@ -53,19 +47,15 @@ import org.thoughtcrime.securesms.util.livedata.Store; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.concurrent.TimeUnit; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.BackpressureStrategy; import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.BehaviorSubject; @@ -97,8 +87,7 @@ public class ConversationViewModel extends ViewModel { private final Observer threadAnimationStateStoreDriver; private final NotificationProfilesRepository notificationProfilesRepository; private final MutableLiveData searchQuery; - - private final Map> sessionMemberCache = new HashMap<>(); + private final GroupAuthorNameColorHelper groupAuthorNameColorHelper; private ConversationIntents.Args args; private int jumpToPosition; @@ -123,6 +112,7 @@ public class ConversationViewModel extends ViewModel { this.searchQuery = new MutableLiveData<>(); this.recipientId = BehaviorSubject.create(); this.threadId = BehaviorSubject.create(); + this.groupAuthorNameColorHelper = new GroupAuthorNameColorHelper(); BehaviorSubject recipientCache = BehaviorSubject.create(); @@ -345,35 +335,13 @@ public class ConversationViewModel extends ViewModel { .observeOn(Schedulers.io()) .distinctUntilChanged() .map(Recipient::resolved) - .map(Recipient::getGroupId) - .map(groupId -> { - if (groupId.isPresent()) { - List fullMembers = SignalDatabase.groups().getGroupMembers(groupId.get(), GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF); - Set cachedMembers = MapUtil.getOrDefault(sessionMemberCache, groupId.get(), new HashSet<>()); - - cachedMembers.addAll(fullMembers); - sessionMemberCache.put(groupId.get(), cachedMembers); - - return cachedMembers; + .map(recipient -> { + if (recipient.getGroupId().isPresent()) { + return groupAuthorNameColorHelper.getColorMap(recipient.getGroupId().get()); } else { - return Collections.emptySet(); + return Collections.emptyMap(); } }) - .map(members -> { - List sorted = Stream.of(members) - .filter(member -> !Objects.equals(member, Recipient.self())) - .sortBy(Recipient::requireStringId) - .toList(); - - List names = ChatColorsPalette.Names.getAll(); - Map colors = new HashMap<>(); - - for (int i = 0; i < sorted.size(); i++) { - colors.put(sorted.get(i).getId(), names.get(i % names.size())); - } - - return colors; - }) .observeOn(AndroidSchedulers.mainThread()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/GroupAuthorNameColorHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/GroupAuthorNameColorHelper.kt new file mode 100644 index 000000000..e17264269 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/GroupAuthorNameColorHelper.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.conversation.colors + +import androidx.annotation.NonNull +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Class to assist managing the colors of author names in the UI in groups. + * We want to be able to map each group member to a color, and for that to + * remain constant throughout a "chat open lifecycle" (i.e. should never + * change while looking at a chat, but can change if you close and open). + */ +class GroupAuthorNameColorHelper { + + /** Needed so that we have a full history of current *and* past members (so colors don't change when someone leaves) */ + private val fullMemberCache: MutableMap> = mutableMapOf() + + /** + * Given a [GroupId], returns a map of member -> name color. + */ + fun getColorMap(@NonNull groupId: GroupId): Map { + val dbMembers: Set = SignalDatabase + .groups + .getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF) + .toSet() + val cachedMembers: Set = fullMemberCache.getOrDefault(groupId, setOf()) + val allMembers: Set = cachedMembers + dbMembers + + fullMemberCache[groupId] = allMembers + + val members: List = allMembers + .filter { member -> member != Recipient.self() } + .sortedBy { obj: Recipient -> obj.requireStringId() } + + val allColors: List = ChatColorsPalette.Names.all + + val colors: MutableMap = HashMap() + for (i in members.indices) { + colors[members[i].id] = allColors[i % allColors.size] + } + + return colors.toMap() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuoteHeaderDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuoteHeaderDecoration.kt new file mode 100644 index 000000000..50429f3e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuoteHeaderDecoration.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.conversation.quotes + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import androidx.core.view.marginEnd +import androidx.core.view.marginLeft +import androidx.core.view.marginStart +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Serves as the separator between the original message and the messages that quote it in [MessageQuotesBottomSheet] + */ +class MessageQuoteHeaderDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val dividerMargin = ViewUtil.dpToPx(context, 32) + private val dividerHeight = ViewUtil.dpToPx(context, 2) + private val dividerRect = Rect() + private val dividerPaint: Paint = Paint().apply { + style = Paint.Style.FILL + color = context.resources.getColor(R.color.signal_colorSurfaceVariant) + } + + private var cachedHeader: View? = null + private val headerMargin = ViewUtil.dpToPx(24) + + override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val lastItem: View = parent.children.firstOrNull { child -> + parent.getChildAdapterPosition(child) == state.itemCount - 1 + } ?: return + + dividerRect.apply { + left = parent.left + top = lastItem.bottom + dividerMargin + right = parent.right + bottom = lastItem.bottom + dividerMargin + dividerHeight + } + + canvas.drawRect(dividerRect, dividerPaint) + + val header = getHeader(parent) + + canvas.save() + canvas.translate((parent.left + header.marginLeft).toFloat(), (dividerRect.bottom + dividerMargin).toFloat()) + header.draw(canvas) + canvas.restore() + } + + private fun getHeader(parent: RecyclerView): View { + cachedHeader?.let { + return it + } + + val header: View = LayoutInflater.from(parent.context).inflate(R.layout.message_quote_header_decoration, parent, false) + + val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width - header.marginStart - header.marginEnd, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) + + val childWidth = ViewGroup.getChildMeasureSpec( + widthSpec, + parent.paddingLeft + parent.paddingRight, + header.layoutParams.width + ) + + val childHeight = ViewGroup.getChildMeasureSpec( + heightSpec, + parent.paddingTop + parent.paddingBottom, + header.layoutParams.height + ) + + header.measure(childWidth, childHeight) + header.layout(header.marginLeft, 0, header.measuredWidth, header.measuredHeight) + + cachedHeader = header + + return header + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val currentPosition = parent.getChildAdapterPosition(view) + val lastPosition = state.itemCount - 1 + + if (currentPosition == lastPosition) { + outRect.bottom = ViewUtil.dpToPx(view.context, 110) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt new file mode 100644 index 000000000..bca08b311 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt @@ -0,0 +1,223 @@ +package org.thoughtcrime.securesms.conversation.quotes + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager +import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.StickyHeaderDecoration +import org.thoughtcrime.securesms.util.fragments.findListener +import java.util.Locale + +class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 0.66f + override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages + + private lateinit var messageAdapter: ConversationAdapter + private val viewModel: MessageQuotesViewModel by viewModels( + factoryProducer = { + val messageId = MessageId.deserialize(arguments?.getString(KEY_MESSAGE_ID, null) ?: throw IllegalArgumentException()) + val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException()) + MessageQuotesViewModel.Factory(ApplicationDependencies.getApplication(), messageId, conversationRecipientId) + } + ) + + private val disposables: LifecycleDisposable = LifecycleDisposable() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.message_quotes_bottom_sheet, container, false) + disposables.bindTo(viewLifecycleOwner) + return view + } + + @SuppressLint("WrongThread") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException()) + val conversationRecipient = Recipient.resolved(conversationRecipientId) + + val colorizer = Colorizer() + + messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply { + setCondensedMode(true) + } + + val list: RecyclerView = view.findViewById(R.id.quotes_list).apply { + layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true) + adapter = messageAdapter + itemAnimator = null + addItemDecoration(MessageQuoteHeaderDecoration(context)) + + doOnNextLayout { + // Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view + addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE)) + } + } + + val recyclerViewColorizer = RecyclerViewColorizer(list) + + disposables += viewModel.getMessages().subscribe { messages -> + messageAdapter.submitList(messages) { + (list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100) + } + recyclerViewColorizer.setChatColors(conversationRecipient.chatColors) + } + + disposables += viewModel.getNameColorsMap().subscribe { map -> + colorizer.onNameColorsChanged(map) + messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapter.PAYLOAD_NAME_COLORS) + } + } + + private fun getCallback(): Callback { + return findListener() ?: throw IllegalStateException("Parent must implement callback interface!") + } + + private fun getAdapterListener(): ConversationAdapter.ItemClickListener { + return getCallback().getConversationAdapterListener() + } + + private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() { + override fun onItemClick(item: MultiselectPart) { + dismiss() + getCallback().jumpToMessage(item.getMessageRecord()) + } + + override fun onItemLongClick(itemView: View, item: MultiselectPart) { + onItemClick(item) + } + + override fun onQuoteClicked(messageRecord: MmsMessageRecord) { + dismiss() + getCallback().jumpToMessage(messageRecord) + } + + override fun onLinkPreviewClicked(linkPreview: LinkPreview) { + dismiss() + getAdapterListener().onLinkPreviewClicked(linkPreview) + } + + override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) { + dismiss() + getAdapterListener().onQuotedIndicatorClicked(messageRecord) + } + + override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) { + dismiss() + getAdapterListener().onReactionClicked(multiselectPart, messageId, isMms) + } + + override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) { + dismiss() + getAdapterListener().onGroupMemberClicked(recipientId, groupId) + } + + override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) { + dismiss() + getAdapterListener().onMessageWithRecaptchaNeededClicked(messageRecord) + } + + override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) { + dismiss() + getAdapterListener().onGroupMigrationLearnMoreClicked(membershipChange) + } + + override fun onChatSessionRefreshLearnMoreClicked() { + dismiss() + getAdapterListener().onChatSessionRefreshLearnMoreClicked() + } + + override fun onBadDecryptLearnMoreClicked(author: RecipientId) { + dismiss() + getAdapterListener().onBadDecryptLearnMoreClicked(author) + } + + override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) { + dismiss() + getAdapterListener().onSafetyNumberLearnMoreClicked(recipient) + } + + override fun onJoinGroupCallClicked() { + dismiss() + getAdapterListener().onJoinGroupCallClicked() + } + + override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) { + dismiss() + getAdapterListener().onInviteFriendsToGroupClicked(groupId) + } + + override fun onEnableCallNotificationsClicked() { + dismiss() + getAdapterListener().onEnableCallNotificationsClicked() + } + + override fun onCallToAction(action: String) { + dismiss() + getAdapterListener().onCallToAction(action) + } + + override fun onDonateClicked() { + dismiss() + getAdapterListener().onDonateClicked() + } + + override fun onRecipientNameClicked(target: RecipientId) { + dismiss() + getAdapterListener().onRecipientNameClicked(target) + } + + override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) { + dismiss() + getAdapterListener().onViewGiftBadgeClicked(messageRecord) + } + } + + interface Callback { + fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener + fun jumpToMessage(messageRecord: MessageRecord) + } + + companion object { + private const val KEY_MESSAGE_ID = "message_id" + private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id" + + @JvmStatic + fun show(fragmentManager: FragmentManager, messageId: MessageId, conversationRecipientId: RecipientId) { + val args = Bundle().apply { + putString(KEY_MESSAGE_ID, messageId.serialize()) + putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize()) + } + + val fragment = MessageQuotesBottomSheet().apply { + arguments = args + } + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesViewModel.kt new file mode 100644 index 000000000..a37f7a39e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesViewModel.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.conversation.quotes + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper +import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class MessageQuotesViewModel( + application: Application, + private val messageId: MessageId, + private val conversationRecipientId: RecipientId +) : AndroidViewModel(application) { + + private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper() + + fun getMessages(): Observable> { + return Observable.create> { emitter -> + val quotes: List = SignalDatabase + .mmsSms + .getAllMessagesThatQuote(messageId) + .map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(getApplication(), it) } + + val originalRecord: MessageRecord? = if (messageId.mms) { + SignalDatabase.mms.getMessageRecordOrNull(messageId.id) + } else { + SignalDatabase.sms.getMessageRecordOrNull(messageId.id) + } + + if (originalRecord != null) { + val originalMessage: ConversationMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(getApplication(), originalRecord, originalRecord.getDisplayBody(getApplication()), 0) + emitter.onNext(quotes + listOf(originalMessage)) + } else { + emitter.onNext(quotes) + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun getNameColorsMap(): Observable> { + return Observable.just(conversationRecipientId) + .map { conversationRecipientId -> + val conversationRecipient = Recipient.resolved(conversationRecipientId) + + if (conversationRecipient.groupId.isPresent) { + groupAuthorNameColorHelper.getColorMap(conversationRecipient.groupId.get()) + } else { + emptyMap() + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + class Factory(private val application: Application, private val messageId: MessageId, private val conversationRecipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T { + return modelClass.cast(MessageQuotesViewModel(application, messageId, conversationRecipientId)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index b9f071df8..73ee5dc08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -199,6 +199,7 @@ public class MmsDatabase extends MessageDatabase { "CREATE INDEX IF NOT EXISTS mms_is_story_index ON " + TABLE_NAME + " (" + STORY_TYPE + ");", "CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");", "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + ");", + "CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON " + TABLE_NAME + "(" + QUOTE_ID + ", " + QUOTE_AUTHOR + ");" }; private static final String[] MMS_PROJECTION = new String[] { @@ -2180,7 +2181,11 @@ public class MmsDatabase extends MessageDatabase { SignalDatabase.threads().updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId); if (!message.getStoryType().isStory()) { - ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId, true)); + if (message.getOutgoingQuote() == null) { + ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId, true)); + } else { + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); + } } else { ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 537e8dfd7..a57e773df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -31,6 +31,7 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.util.Pair; import org.thoughtcrime.securesms.database.MessageDatabase.MessageUpdate; import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.notifications.v2.MessageNotifierV2; @@ -273,6 +274,45 @@ public class MmsSmsDatabase extends Database { return queryTables(PROJECTION, selection, order, null, true); } + /** + * The number of messages that quote the target message + */ + public int getQuotedCount(@NonNull MessageRecord messageRecord) { + RecipientId author = messageRecord.isOutgoing() ? Recipient.self().getId() : messageRecord.getRecipient().getId(); + long timestamp = messageRecord.getDateSent(); + + String where = MmsDatabase.QUOTE_ID + " = ? AND " + MmsDatabase.QUOTE_AUTHOR + " = ?"; + String[] whereArgs = SqlUtil.buildArgs(timestamp, author); + + try (Cursor cursor = getReadableDatabase().query(MmsDatabase.TABLE_NAME, COUNT, where, whereArgs, null, null, null)) { + return cursor.moveToFirst() ? cursor.getInt(0) : 0; + } + } + + public List getAllMessagesThatQuote(@NonNull MessageId id) { + MessageRecord targetMessage; + try { + targetMessage = id.isMms() ? SignalDatabase.mms().getMessageRecord(id.getId()) : SignalDatabase.sms().getMessageRecord(id.getId()); + } catch (NoSuchMessageException e) { + throw new IllegalArgumentException("Invalid message ID!"); + } + + RecipientId author = targetMessage.isOutgoing() ? Recipient.self().getId() : targetMessage.getRecipient().getId(); + String query = MmsDatabase.QUOTE_ID + " = " + targetMessage.getDateSent() + " AND " + MmsDatabase.QUOTE_AUTHOR + " = " + author.serialize(); + String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + + List records = new ArrayList<>(); + + try (Reader reader = new Reader(queryTables(PROJECTION, query, order, null, true))) { + MessageRecord record; + while ((record = reader.getNext()) != null) { + records.add(record); + } + } + + return records; + } + private @NonNull String getStickyWherePartForParentStoryId(@Nullable Long parentStoryId) { if (parentStoryId == null) { return " AND " + MmsDatabase.PARENT_STORY_ID + " <= 0"; 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 1e9ccbbe1..a30cf5ec6 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 @@ -200,8 +200,9 @@ object SignalDatabaseMigrations { private const val GROUP_STORY_NOTIFICATIONS = 144 private const val GROUP_STORY_REPLY_CLEANUP = 145 private const val REMOTE_MEGAPHONE = 146 + private const val QUOTE_INDEX = 147 - const val DATABASE_VERSION = 146 + const val DATABASE_VERSION = 147 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -2613,6 +2614,14 @@ object SignalDatabaseMigrations { """ ) } + + if (oldVersion < QUOTE_INDEX) { + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON mms (quote_id, quote_author) + """ + ) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index 7323b11d6..16f0a2dd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -111,7 +111,8 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G false, false, true, - colorizer); + colorizer, + false); } private void bindErrorState(MessageRecord messageRecord) { diff --git a/app/src/main/res/drawable/ic_replies_outline_20.xml b/app/src/main/res/drawable/ic_replies_outline_20.xml new file mode 100644 index 000000000..a83aeed6e --- /dev/null +++ b/app/src/main/res/drawable/ic_replies_outline_20.xml @@ -0,0 +1,10 @@ + + + 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 5e86c7324..59c6d6146 100644 --- a/app/src/main/res/layout/conversation_item_received_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_received_multimedia.xml @@ -75,7 +75,8 @@ android:clipChildren="false" android:clipToPadding="false" android:orientation="vertical" - tools:backgroundTint="@color/conversation_blue"> + tools:background="@drawable/message_bubble_background_received_alone" + tools:backgroundTint="@color/signal_colorSurfaceVariant"> + android:layout_margin="8dp" + android:layout="@layout/conversation_item_call_to_action" /> + android:orientation="vertical" + android:visibility="gone"/> + + + + + + + tools:background="@drawable/message_bubble_background_received_alone" + tools:backgroundTint="@color/conversation_blue"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/message_quotes_bottom_sheet.xml b/app/src/main/res/layout/message_quotes_bottom_sheet.xml new file mode 100644 index 000000000..8e716c462 --- /dev/null +++ b/app/src/main/res/layout/message_quotes_bottom_sheet.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 025c6c834..16db8c9ec 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -54,6 +54,7 @@ 240dp 100dp 320dp + 150dp 175dp 240dp 242dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3153b7e2b..1e268edcc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1041,6 +1041,9 @@ Appearance Add photo + + Replies + Signal call in progress Establishing Signal call diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d8cbdf58f..3b8e6da65 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -470,6 +470,16 @@ 0dp + + + +