Add the ability to see replies.
This commit is contained in:
parent
ee4f3abf22
commit
6ec7834046
28 changed files with 832 additions and 91 deletions
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<MessageRecord> 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<MultiselectPart> 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<Slide> 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<MessageRecord> previous, @NonNull Optional<MessageRecord> 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<Slide> slides) {
|
||||
|
|
|
@ -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<Mention> mentions)
|
||||
@Nullable List<Mention> 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<Mention> mentions) {
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List<Mention> 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<Mention> 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<Mention> 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<Mention> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<ThreadAnimationState> threadAnimationStateStoreDriver;
|
||||
private final NotificationProfilesRepository notificationProfilesRepository;
|
||||
private final MutableLiveData<String> searchQuery;
|
||||
|
||||
private final Map<GroupId, Set<Recipient>> 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<Recipient> 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<Recipient> fullMembers = SignalDatabase.groups().getGroupMembers(groupId.get(), GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF);
|
||||
Set<Recipient> 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.<Recipient>emptySet();
|
||||
return Collections.<RecipientId, NameColor>emptyMap();
|
||||
}
|
||||
})
|
||||
.map(members -> {
|
||||
List<Recipient> sorted = Stream.of(members)
|
||||
.filter(member -> !Objects.equals(member, Recipient.self()))
|
||||
.sortBy(Recipient::requireStringId)
|
||||
.toList();
|
||||
|
||||
List<NameColor> names = ChatColorsPalette.Names.getAll();
|
||||
Map<RecipientId, NameColor> 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());
|
||||
}
|
||||
|
||||
|
|
|
@ -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<GroupId, Set<Recipient>> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Given a [GroupId], returns a map of member -> name color.
|
||||
*/
|
||||
fun getColorMap(@NonNull groupId: GroupId): Map<RecipientId, NameColor> {
|
||||
val dbMembers: Set<Recipient> = SignalDatabase
|
||||
.groups
|
||||
.getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
|
||||
.toSet()
|
||||
val cachedMembers: Set<Recipient> = fullMemberCache.getOrDefault(groupId, setOf())
|
||||
val allMembers: Set<Recipient> = cachedMembers + dbMembers
|
||||
|
||||
fullMemberCache[groupId] = allMembers
|
||||
|
||||
val members: List<Recipient> = allMembers
|
||||
.filter { member -> member != Recipient.self() }
|
||||
.sortedBy { obj: Recipient -> obj.requireStringId() }
|
||||
|
||||
val allColors: List<NameColor> = ChatColorsPalette.Names.all
|
||||
|
||||
val colors: MutableMap<RecipientId, NameColor> = HashMap()
|
||||
for (i in members.indices) {
|
||||
colors[members[i].id] = allColors[i % allColors.size]
|
||||
}
|
||||
|
||||
return colors.toMap()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<RecyclerView>(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<Callback>() ?: 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<List<ConversationMessage>> {
|
||||
return Observable.create<List<ConversationMessage>> { emitter ->
|
||||
val quotes: List<ConversationMessage> = 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<Map<RecipientId, NameColor>> {
|
||||
return Observable.just(conversationRecipientId)
|
||||
.map { conversationRecipientId ->
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
if (conversationRecipient.groupId.isPresent) {
|
||||
groupAuthorNameColorHelper.getColorMap(conversationRecipient.groupId.get())
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory(private val application: Application, private val messageId: MessageId, private val conversationRecipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(MessageQuotesViewModel(application, messageId, conversationRecipientId)) as T
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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<MessageRecord> 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<MessageRecord> 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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -111,7 +111,8 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
|
|||
false,
|
||||
false,
|
||||
true,
|
||||
colorizer);
|
||||
colorizer,
|
||||
false);
|
||||
}
|
||||
|
||||
private void bindErrorState(MessageRecord messageRecord) {
|
||||
|
|
10
app/src/main/res/drawable/ic_replies_outline_20.xml
Normal file
10
app/src/main/res/drawable/ic_replies_outline_20.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7.583,2.833c-2.881,0 -5.083,2.093 -5.083,4.518 0,0.99 0.359,1.911 0.979,2.665 0.378,0.458 0.625,1.091 0.548,1.769l-0.104,0.921 1.161,-0.713c0.436,-0.267 0.93,-0.325 1.378,-0.235l0.084,0.016c-0.003,0.08 -0.005,0.16 -0.005,0.241 0,0.448 0.048,0.882 0.138,1.297 -0.173,-0.022 -0.343,-0.049 -0.512,-0.083 -0.124,-0.025 -0.224,-0.004 -0.299,0.042l-1.72,1.057c-0.449,0.276 -0.959,0.197 -1.307,-0.071 -0.338,-0.261 -0.526,-0.692 -0.474,-1.148l0.169,-1.492c0.024,-0.211 -0.052,-0.449 -0.215,-0.647 -0.825,-1.001 -1.322,-2.254 -1.322,-3.619C1,3.957 4.021,1.333 7.583,1.333c2.935,0 5.503,1.782 6.316,4.308 -0.16,-0.011 -0.321,-0.016 -0.483,-0.016 -0.371,0 -0.739,0.028 -1.1,0.083 -0.731,-1.656 -2.534,-2.875 -4.733,-2.875ZM17.333,12.063c0,-1.76 -1.605,-3.314 -3.75,-3.314s-3.75,1.554 -3.75,3.314 1.605,3.314 3.75,3.314c0.286,0 0.564,-0.028 0.831,-0.082 0.383,-0.077 0.809,-0.028 1.186,0.203l0.596,0.366 -0.041,-0.361c-0.065,-0.577 0.145,-1.11 0.461,-1.493 0.456,-0.553 0.716,-1.225 0.716,-1.947ZM13.583,7.249c2.826,0 5.25,2.085 5.25,4.814 0,1.095 -0.399,2.1 -1.059,2.901 -0.101,0.123 -0.14,0.262 -0.128,0.371l0.13,1.151c0.046,0.411 -0.123,0.802 -0.431,1.041 -0.319,0.246 -0.789,0.319 -1.202,0.065l-1.327,-0.815c-0.016,-0.01 -0.049,-0.022 -0.107,-0.011 -0.363,0.073 -0.74,0.111 -1.125,0.111 -2.826,0 -5.25,-2.085 -5.25,-4.814s2.424,-4.814 5.25,-4.814Z"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
|
@ -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">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/group_sender_holder"
|
||||
|
@ -140,6 +141,7 @@
|
|||
app:message_type="incoming"
|
||||
app:quote_colorPrimary="@color/conversation_item_quote_text_color"
|
||||
app:quote_colorSecondary="@color/conversation_item_quote_text_color"
|
||||
tools:background="@color/transparent_black_05"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ViewStub
|
||||
|
@ -225,8 +227,8 @@
|
|||
android:id="@+id/conversation_item_call_to_action_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout="@layout/conversation_item_call_to_action"
|
||||
android:layout_margin="8dp" />
|
||||
android:layout_margin="8dp"
|
||||
android:layout="@layout/conversation_item_call_to_action" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.ConversationItemFooter
|
||||
android:id="@+id/conversation_item_footer"
|
||||
|
@ -270,9 +272,31 @@
|
|||
android:id="@+id/indicators_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_toStartOf="@id/quoted_indicator"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical" />
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignEnd="@id/body_bubble"
|
||||
android:layout_alignTop="@id/body_bubble"
|
||||
android:layout_alignBottom="@id/body_bubble"
|
||||
android:layout_marginEnd="-42dp" >
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/quoted_indicator"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="@drawable/circle_tintable"
|
||||
android:backgroundTint="@color/signal_colorSurfaceVariant"
|
||||
android:padding="6dp"
|
||||
android:tint="@color/signal_colorOnSurfaceVariant"
|
||||
app:srcCompat="@drawable/ic_replies_outline_20" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.reactions.ReactionsConversationView
|
||||
android:id="@+id/reactions_view"
|
||||
|
|
|
@ -44,7 +44,8 @@
|
|||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
tools:backgroundTint="@color/core_grey_05">
|
||||
tools:background="@drawable/message_bubble_background_received_alone"
|
||||
tools:backgroundTint="@color/conversation_blue">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/story_reacted_label_holder"
|
||||
|
@ -82,6 +83,7 @@
|
|||
app:message_type="outgoing"
|
||||
app:quote_colorPrimary="@color/conversation_item_quote_text_color_sent"
|
||||
app:quote_colorSecondary="@color/conversation_item_quote_text_color_sent"
|
||||
tools:background="@color/transparent_white_10"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ViewStub
|
||||
|
@ -202,12 +204,33 @@
|
|||
android:id="@+id/indicators_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignBottom="@id/body_bubble"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginStart="8dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignStart="@id/body_bubble"
|
||||
android:layout_alignTop="@id/body_bubble"
|
||||
android:layout_alignBottom="@id/body_bubble"
|
||||
android:layout_marginStart="-42dp" >
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/quoted_indicator"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="@drawable/circle_tintable"
|
||||
android:backgroundTint="@color/signal_colorSurfaceVariant"
|
||||
android:padding="6dp"
|
||||
android:tint="@color/signal_colorOnSurfaceVariant"
|
||||
app:srcCompat="@drawable/ic_replies_outline_20" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.reactions.ReactionsConversationView
|
||||
android:id="@+id/reactions_view"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
11
app/src/main/res/layout/message_quote_header_decoration.xml
Normal file
11
app/src/main/res/layout/message_quote_header_decoration.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end"
|
||||
android:text="@string/MessageQuotesBottomSheet_replies"
|
||||
style="@style/Signal.Text.TitleSmall"/>
|
21
app/src/main/res/layout/message_quotes_bottom_sheet.xml
Normal file
21
app/src/main/res/layout/message_quotes_bottom_sheet.xml
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/anchor"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="2dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/signal_icon_tint_tab_unselected" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/quotes_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
|
@ -54,6 +54,7 @@
|
|||
<dimen name="media_bubble_max_width">240dp</dimen>
|
||||
<dimen name="media_bubble_min_height">100dp</dimen>
|
||||
<dimen name="media_bubble_max_height">320dp</dimen>
|
||||
<dimen name="media_bubble_max_height_condensed">150dp</dimen>
|
||||
<dimen name="media_bubble_sticker_dimens">175dp</dimen>
|
||||
<dimen name="media_bubble_gif_width">240dp</dimen>
|
||||
<dimen name="message_audio_width">242dp</dimen>
|
||||
|
|
|
@ -1041,6 +1041,9 @@
|
|||
<string name="Megaphones_appearance">Appearance</string>
|
||||
<string name="Megaphones_add_photo">Add photo</string>
|
||||
|
||||
<!-- Title of a bottom sheet to render messages that all quote a specific message -->
|
||||
<string name="MessageQuotesBottomSheet_replies">Replies</string>
|
||||
|
||||
<!-- NotificationBarManager -->
|
||||
<string name="NotificationBarManager_signal_call_in_progress">Signal call in progress</string>
|
||||
<string name="NotificationBarManager__establishing_signal_call">Establishing Signal call</string>
|
||||
|
|
|
@ -470,6 +470,16 @@
|
|||
<item name="android:elevation" tools:ignore="NewApi">0dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Signal.FixedRoundedCorners.Messages" parent="Widget.Signal.FixedRoundedCorners">
|
||||
<item name="bottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners.Messages.BottomSheet</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Signal.FixedRoundedCorners.Messages.BottomSheet" parent="Widget.Material3.BottomSheet">
|
||||
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.Signal.BottomSheet.Rounded</item>
|
||||
<item name="backgroundTint">@color/signal_colorSurface</item>
|
||||
<item name="android:elevation" tools:ignore="NewApi">0dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Signal.FixedRoundedCorners.Stories">
|
||||
<item name="bottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners.BottomSheet.Stories</item>
|
||||
<item name="android:navigationBarColor" tools:targetApi="lollipop">@color/signal_colorSurface</item>
|
||||
|
|
Loading…
Add table
Reference in a new issue