mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 02:39:55 +01:00
Add mentions for v2 group chats.
This commit is contained in:
committed by
Greyson Parrelli
parent
0bb9c1d650
commit
b2d4c5d14b
@@ -5,6 +5,7 @@ import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.Spannable;
|
||||
@@ -13,7 +14,6 @@ import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.text.method.QwertyKeyListener;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
@@ -21,7 +21,6 @@ import android.view.inputmethod.InputConnection;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.BuildCompat;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
@@ -30,18 +29,26 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
|
||||
public class ComposeText extends EmojiEditText {
|
||||
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private CharSequence combinedHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@@ -62,47 +69,63 @@ public class ComposeText extends EmojiEditText {
|
||||
initialize();
|
||||
}
|
||||
|
||||
public String getTextTrimmed(){
|
||||
return getText().toString().trim();
|
||||
/**
|
||||
* Trims and returns text while preserving potential spans like {@link MentionAnnotation}.
|
||||
*/
|
||||
public @NonNull CharSequence getTextTrimmed() {
|
||||
Editable text = getText();
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return StringUtil.trimSequence(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||
super.onLayout(changed, left, top, right, bottom);
|
||||
|
||||
if (!TextUtils.isEmpty(hint)) {
|
||||
if (!TextUtils.isEmpty(subHint)) {
|
||||
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(subHint)));
|
||||
} else {
|
||||
setHint(ellipsizeToWidth(hint));
|
||||
}
|
||||
if (!TextUtils.isEmpty(combinedHint)) {
|
||||
setHint(combinedHint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
protected void onSelectionChanged(int selectionStart, int selectionEnd) {
|
||||
super.onSelectionChanged(selectionStart, selectionEnd);
|
||||
|
||||
if (FeatureFlags.mentions()) {
|
||||
if (selStart == selEnd) {
|
||||
doAfterCursorChange();
|
||||
if (FeatureFlags.mentions() && getText() != null) {
|
||||
boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd);
|
||||
if (selectionChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionStart == selectionEnd) {
|
||||
doAfterCursorChange(getText());
|
||||
} else {
|
||||
updateQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorPositionChangedListener != null) {
|
||||
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
|
||||
cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (FeatureFlags.mentions() && getText() != null && getLayout() != null) {
|
||||
if (getText() != null && getLayout() != null) {
|
||||
int checkpoint = canvas.save();
|
||||
|
||||
// Clip using same logic as TextView drawing
|
||||
int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop();
|
||||
float clipLeft = getCompoundPaddingLeft() + getScrollX();
|
||||
float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY();
|
||||
float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX();
|
||||
float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom());
|
||||
|
||||
canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom);
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, getText(), getLayout());
|
||||
} finally {
|
||||
@@ -120,25 +143,25 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
|
||||
this.hint = hint;
|
||||
|
||||
if (subHint != null) {
|
||||
this.subHint = new SpannableString(subHint);
|
||||
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
|
||||
Spannable subHintSpannable = new SpannableString(subHint);
|
||||
subHintSpannable.setSpan(new RelativeSizeSpan(0.5f), 0, subHintSpannable.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
|
||||
|
||||
combinedHint = new SpannableStringBuilder().append(ellipsizeToWidth(hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(subHintSpannable));
|
||||
} else {
|
||||
this.subHint = null;
|
||||
combinedHint = ellipsizeToWidth(hint);
|
||||
}
|
||||
|
||||
if (this.subHint != null) {
|
||||
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(this.subHint)));
|
||||
} else {
|
||||
super.setHint(ellipsizeToWidth(this.hint));
|
||||
}
|
||||
super.setHint(combinedHint);
|
||||
}
|
||||
|
||||
public void appendInvite(String invite) {
|
||||
if (getText() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
|
||||
append(" ");
|
||||
}
|
||||
@@ -155,13 +178,18 @@ public class ComposeText extends EmojiEditText {
|
||||
this.mentionQueryChangedListener = listener;
|
||||
}
|
||||
|
||||
public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) {
|
||||
if (FeatureFlags.mentions()) {
|
||||
mentionValidatorWatcher.setMentionValidator(mentionValidator);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLandscape() {
|
||||
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
}
|
||||
|
||||
public void setTransport(TransportOption transport) {
|
||||
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
|
||||
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
|
||||
|
||||
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
|
||||
int inputType = getInputType();
|
||||
@@ -201,19 +229,59 @@ public class ComposeText extends EmojiEditText {
|
||||
this.mediaListener = mediaListener;
|
||||
}
|
||||
|
||||
public boolean hasMentions() {
|
||||
Editable text = getText();
|
||||
if (text != null) {
|
||||
return !MentionAnnotation.getMentionAnnotations(text).isEmpty();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return MentionAnnotation.getMentionsFromAnnotations(getText());
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
||||
setImeOptions(getImeOptions() | 16777216);
|
||||
}
|
||||
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ThemeUtil.getThemedColor(getContext(), R.attr.conversation_mention_background_color));
|
||||
|
||||
if (FeatureFlags.mentions()) {
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext());
|
||||
addTextChangedListener(new MentionDeleter());
|
||||
mentionValidatorWatcher = new MentionValidatorWatcher();
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
}
|
||||
}
|
||||
|
||||
private void doAfterCursorChange() {
|
||||
Editable text = getText();
|
||||
if (text != null && enoughToFilter(text)) {
|
||||
private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) {
|
||||
Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class);
|
||||
for (Annotation annotation : annotations) {
|
||||
if (MentionAnnotation.isMentionAnnotation(annotation)) {
|
||||
int spanStart = spanned.getSpanStart(annotation);
|
||||
int spanEnd = spanned.getSpanEnd(annotation);
|
||||
|
||||
boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd;
|
||||
boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd;
|
||||
|
||||
if (startInMention || endInMention) {
|
||||
if (selectionStart == selectionEnd) {
|
||||
setSelection(spanEnd, spanEnd);
|
||||
} else {
|
||||
int newStart = startInMention ? spanStart : selectionStart;
|
||||
int newEnd = endInMention ? spanEnd : selectionEnd;
|
||||
setSelection(newStart, newEnd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void doAfterCursorChange(@NonNull Editable text) {
|
||||
if (enoughToFilter(text)) {
|
||||
performFiltering(text);
|
||||
} else {
|
||||
updateQuery("");
|
||||
@@ -241,7 +309,7 @@ public class ComposeText extends EmojiEditText {
|
||||
return end - findQueryStart(text, end) >= 1;
|
||||
}
|
||||
|
||||
public void replaceTextWithMention(@NonNull String displayName, @NonNull UUID uuid) {
|
||||
public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) {
|
||||
Editable text = getText();
|
||||
if (text == null) {
|
||||
return;
|
||||
@@ -251,14 +319,12 @@ public class ComposeText extends EmojiEditText {
|
||||
|
||||
int end = getSelectionEnd();
|
||||
int start = findQueryStart(text, end) - 1;
|
||||
String original = TextUtils.substring(text, start, end);
|
||||
|
||||
QwertyKeyListener.markAsReplaced(text, start, end, original);
|
||||
text.replace(start, end, createReplacementToken(displayName, uuid));
|
||||
text.replace(start, end, createReplacementToken(displayName, recipientId));
|
||||
}
|
||||
|
||||
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder("@");
|
||||
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER);
|
||||
if (text instanceof Spanned) {
|
||||
SpannableString spannableString = new SpannableString(text + " ");
|
||||
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
|
||||
@@ -267,7 +333,7 @@ public class ComposeText extends EmojiEditText {
|
||||
builder.append(text).append(" ");
|
||||
}
|
||||
|
||||
builder.setSpan(MentionAnnotation.mentionAnnotationForUuid(uuid), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -278,11 +344,11 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
int delimiterSearchIndex = inputCursorPosition - 1;
|
||||
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != '@' && text.charAt(delimiterSearchIndex) != ' ')) {
|
||||
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) {
|
||||
delimiterSearchIndex--;
|
||||
}
|
||||
|
||||
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') {
|
||||
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) {
|
||||
return delimiterSearchIndex + 1;
|
||||
}
|
||||
return inputCursorPosition;
|
||||
@@ -300,7 +366,7 @@ public class ComposeText extends EmojiEditText {
|
||||
|
||||
@Override
|
||||
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
|
||||
if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission();
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -2,10 +2,8 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.KeyEvent;
|
||||
@@ -94,7 +92,6 @@ public class InputPanel extends LinearLayout
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
|
||||
public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
@@ -160,7 +157,7 @@ public class InputPanel extends LinearLayout
|
||||
public void setQuote(@NonNull GlideRequests glideRequests,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@NonNull String body,
|
||||
@NonNull CharSequence body,
|
||||
@NonNull SlideDeck attachments)
|
||||
{
|
||||
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments);
|
||||
@@ -228,7 +225,7 @@ public class InputPanel extends LinearLayout
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody(), false, quoteView.getAttachments()));
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
@@ -55,7 +57,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
private long id;
|
||||
private LiveRecipient author;
|
||||
private String body;
|
||||
private CharSequence body;
|
||||
private TextView mediaDescriptionText;
|
||||
private TextView missingLinkText;
|
||||
private SlideDeck attachments;
|
||||
@@ -147,7 +149,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
public void setQuote(GlideRequests glideRequests,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@Nullable String body,
|
||||
@Nullable CharSequence body,
|
||||
boolean originalMissing,
|
||||
@NonNull SlideDeck attachments)
|
||||
{
|
||||
@@ -196,7 +198,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing));
|
||||
}
|
||||
|
||||
private void setQuoteText(@Nullable String body, @NonNull SlideDeck attachments) {
|
||||
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
|
||||
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
|
||||
bodyView.setVisibility(VISIBLE);
|
||||
bodyView.setText(body == null ? "" : body);
|
||||
@@ -280,11 +282,15 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
return author.get();
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
public CharSequence getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public List<Attachment> getAttachments() {
|
||||
return attachments.asAttachments();
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return MentionAnnotation.getMentionsFromAnnotations(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
@@ -15,10 +20,15 @@ import android.util.TypedValue;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
@@ -35,6 +45,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private int maxLength;
|
||||
private CharSequence overflowText;
|
||||
private CharSequence previousOverflowText;
|
||||
private boolean renderMentions;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
|
||||
public EmojiTextView(Context context) {
|
||||
this(context, null);
|
||||
@@ -48,14 +61,33 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
|
||||
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
|
||||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
|
||||
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
|
||||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
|
||||
a.recycle();
|
||||
|
||||
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
|
||||
originalFontSize = a.getDimensionPixelSize(0, 0);
|
||||
a.recycle();
|
||||
|
||||
if (renderMentions) {
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (renderMentions && getText() instanceof Spanned && getLayout() != null) {
|
||||
int checkpoint = canvas.save();
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
@Override public void setText(@Nullable CharSequence text, BufferType type) {
|
||||
@@ -115,7 +147,19 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private void ellipsizeAnyTextForMaxLength() {
|
||||
if (maxLength > 0 && getText().length() > maxLength + 1) {
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or(""));
|
||||
|
||||
CharSequence shortenedText = getText().subSequence(0, maxLength);
|
||||
if (shortenedText instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) shortenedText;
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength);
|
||||
if (!mentionAnnotations.isEmpty()) {
|
||||
shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0)));
|
||||
}
|
||||
}
|
||||
|
||||
newContent.append(shortenedText)
|
||||
.append(ELLIPSIS)
|
||||
.append(Util.emptyIfNull(overflowText));
|
||||
|
||||
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
|
||||
|
||||
|
||||
@@ -2,16 +2,26 @@ package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Spannable;
|
||||
import android.text.Spanned;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.UUID;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Factory for creating mention annotation spans.
|
||||
* This wraps an Android standard {@link Annotation} so it can leverage the built in
|
||||
* span parceling for copy/paste. The annotation span contains the mentioned recipient's
|
||||
* id (in numerical form).
|
||||
*
|
||||
* Note: This wraps creating an Android standard {@link Annotation} so it can leverage the built in
|
||||
* span parceling for copy/paste. Do not extend Annotation or this will be lost.
|
||||
* Note: Do not extend Annotation or the parceling behavior will be lost.
|
||||
*/
|
||||
public final class MentionAnnotation {
|
||||
|
||||
@@ -20,7 +30,45 @@ public final class MentionAnnotation {
|
||||
private MentionAnnotation() {
|
||||
}
|
||||
|
||||
public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) {
|
||||
return new Annotation(MENTION_ANNOTATION, uuid.toString());
|
||||
public static Annotation mentionAnnotationForRecipientId(@NonNull RecipientId id) {
|
||||
return new Annotation(MENTION_ANNOTATION, idToMentionAnnotationValue(id));
|
||||
}
|
||||
|
||||
public static String idToMentionAnnotationValue(@NonNull RecipientId id) {
|
||||
return String.valueOf(id.toLong());
|
||||
}
|
||||
|
||||
public static boolean isMentionAnnotation(@NonNull Annotation annotation) {
|
||||
return MENTION_ANNOTATION.equals(annotation.getKey());
|
||||
}
|
||||
|
||||
public static void setMentionAnnotations(Spannable body, List<Mention> mentions) {
|
||||
for (Mention mention : mentions) {
|
||||
body.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(mention.getRecipientId()), mention.getStart(), mention.getStart() + mention.getLength(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
|
||||
if (text instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) text;
|
||||
return Stream.of(getMentionAnnotations(spanned))
|
||||
.map(annotation -> {
|
||||
int spanStart = spanned.getSpanStart(annotation);
|
||||
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
|
||||
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned) {
|
||||
return getMentionAnnotations(spanned, 0, spanned.length());
|
||||
}
|
||||
|
||||
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
|
||||
return Stream.of(spanned.getSpans(start, end, Annotation.class))
|
||||
.filter(MentionAnnotation::isMentionAnnotation)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextWatcher;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
|
||||
/**
|
||||
* Detects if some part of the mention is being deleted, and if so, deletes the entire mention and
|
||||
* span from the text view.
|
||||
*/
|
||||
public class MentionDeleter implements TextWatcher {
|
||||
|
||||
@Nullable private Annotation toDelete;
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence sequence, int start, int count, int after) {
|
||||
if (count > 0 && sequence instanceof Spanned) {
|
||||
Spanned text = (Spanned) sequence;
|
||||
|
||||
for (Annotation annotation : MentionAnnotation.getMentionAnnotations(text, start, start + count)) {
|
||||
if (text.getSpanStart(annotation) < start && text.getSpanEnd(annotation) > start) {
|
||||
toDelete = annotation;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
if (toDelete == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int toDeleteStart = editable.getSpanStart(toDelete);
|
||||
int toDeleteEnd = editable.getSpanEnd(toDelete);
|
||||
editable.removeSpan(toDelete);
|
||||
toDelete = null;
|
||||
|
||||
editable.replace(toDeleteStart, toDeleteEnd, String.valueOf(MENTION_STARTER));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence sequence, int start, int before, int count) { }
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import android.text.Spanned;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
@@ -28,42 +28,32 @@ public class MentionRendererDelegate {
|
||||
private final MentionRenderer multi;
|
||||
private final int horizontalPadding;
|
||||
|
||||
public MentionRendererDelegate(@NonNull Context context) {
|
||||
//noinspection ConstantConditions
|
||||
this(ViewUtil.dpToPx(2),
|
||||
ViewUtil.dpToPx(2),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid),
|
||||
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right),
|
||||
ThemeUtil.getThemedColor(context, R.attr.conversation_mention_background_color));
|
||||
}
|
||||
public MentionRendererDelegate(@NonNull Context context, @ColorInt int tint) {
|
||||
this.horizontalPadding = ViewUtil.dpToPx(2);
|
||||
|
||||
public MentionRendererDelegate(int horizontalPadding,
|
||||
int verticalPadding,
|
||||
@NonNull Drawable drawable,
|
||||
@NonNull Drawable drawableLeft,
|
||||
@NonNull Drawable drawableMid,
|
||||
@NonNull Drawable drawableEnd,
|
||||
@ColorInt int tint)
|
||||
{
|
||||
this.horizontalPadding = horizontalPadding;
|
||||
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
|
||||
verticalPadding,
|
||||
DrawableUtil.tint(drawable, tint));
|
||||
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
|
||||
verticalPadding,
|
||||
DrawableUtil.tint(drawableLeft, tint),
|
||||
DrawableUtil.tint(drawableMid, tint),
|
||||
DrawableUtil.tint(drawableEnd, tint));
|
||||
Drawable drawable = ContextCompat.getDrawable(context, R.drawable.mention_text_bg);
|
||||
Drawable drawableLeft = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left);
|
||||
Drawable drawableMid = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid);
|
||||
Drawable drawableEnd = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
|
||||
0,
|
||||
DrawableUtil.tint(drawable, tint));
|
||||
//noinspection ConstantConditions
|
||||
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
|
||||
0,
|
||||
DrawableUtil.tint(drawableLeft, tint),
|
||||
DrawableUtil.tint(drawableMid, tint),
|
||||
DrawableUtil.tint(drawableEnd, tint));
|
||||
}
|
||||
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) {
|
||||
Annotation[] spans = text.getSpans(0, text.length(), Annotation.class);
|
||||
for (Annotation span : spans) {
|
||||
if (MentionAnnotation.MENTION_ANNOTATION.equals(span.getKey())) {
|
||||
int spanStart = text.getSpanStart(span);
|
||||
int spanEnd = text.getSpanEnd(span);
|
||||
Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class);
|
||||
for (Annotation annotation : annotations) {
|
||||
if (MentionAnnotation.isMentionAnnotation(annotation)) {
|
||||
int spanStart = text.getSpanStart(annotation);
|
||||
int spanEnd = text.getSpanEnd(annotation);
|
||||
int startLine = layout.getLineForOffset(spanStart);
|
||||
int endLine = layout.getLineForOffset(spanEnd);
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextWatcher;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Provides a mechanism to validate mention annotations set on an edit text. This enables
|
||||
* removing invalid mentions if the user mentioned isn't in the group.
|
||||
*/
|
||||
public class MentionValidatorWatcher implements TextWatcher {
|
||||
|
||||
@Nullable private List<Annotation> invalidMentionAnnotations;
|
||||
@Nullable private MentionValidator mentionValidator;
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence sequence, int start, int before, int count) {
|
||||
if (count > 1 && mentionValidator != null && sequence instanceof Spanned) {
|
||||
Spanned span = (Spanned) sequence;
|
||||
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(span, start, start + count);
|
||||
|
||||
if (mentionAnnotations.size() > 0) {
|
||||
invalidMentionAnnotations = mentionValidator.getInvalidMentionAnnotations(mentionAnnotations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
if (invalidMentionAnnotations == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Annotation> invalidMentions = invalidMentionAnnotations;
|
||||
invalidMentionAnnotations = null;
|
||||
|
||||
for (Annotation annotation : invalidMentions) {
|
||||
editable.removeSpan(annotation);
|
||||
}
|
||||
}
|
||||
|
||||
public void setMentionValidator(@Nullable MentionValidator mentionValidator) {
|
||||
this.mentionValidator = mentionValidator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { }
|
||||
|
||||
public interface MentionValidator {
|
||||
List<Annotation> getInvalidMentionAnnotations(List<Annotation> mentionAnnotations);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user