diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
index c711bb5417..923069c7f2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
@@ -2,40 +2,50 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.Configuration;
+import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
-import androidx.core.view.inputmethod.EditorInfoCompat;
-import androidx.core.view.inputmethod.InputConnectionCompat;
-import androidx.core.view.inputmethod.InputContentInfoCompat;
-import androidx.core.os.BuildCompat;
-
+import android.text.Editable;
import android.text.InputType;
import android.text.Spannable;
import android.text.SpannableString;
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 org.thoughtcrime.securesms.logging.Log;
import android.view.inputmethod.EditorInfo;
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;
+
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.MentionRendererDelegate;
+import org.thoughtcrime.securesms.logging.Log;
+import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import java.util.UUID;
+
public class ComposeText extends EmojiEditText {
- private CharSequence hint;
- private SpannableString subHint;
+ private CharSequence hint;
+ private SpannableString subHint;
+ private MentionRendererDelegate mentionRendererDelegate;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
+ @Nullable private MentionQueryChangedListener mentionQueryChangedListener;
public ComposeText(Context context) {
super(context);
@@ -75,11 +85,33 @@ public class ComposeText extends EmojiEditText {
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
+ if (FeatureFlags.mentions()) {
+ if (selStart == selEnd) {
+ doAfterCursorChange();
+ } else {
+ updateQuery("");
+ }
+ }
+
if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
}
}
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (FeatureFlags.mentions() && getText() != null && getLayout() != null) {
+ int checkpoint = canvas.save();
+ canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
+ try {
+ mentionRendererDelegate.draw(canvas, getText(), getLayout());
+ } finally {
+ canvas.restoreToCount(checkpoint);
+ }
+ }
+ super.onDraw(canvas);
+ }
+
private CharSequence ellipsizeToWidth(CharSequence text) {
return TextUtils.ellipsize(text,
getPaint(),
@@ -119,6 +151,10 @@ public class ComposeText extends EmojiEditText {
this.cursorPositionChangedListener = listener;
}
+ public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) {
+ this.mentionQueryChangedListener = listener;
+ }
+
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
@@ -169,9 +205,89 @@ public class ComposeText extends EmojiEditText {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}
+
+ if (FeatureFlags.mentions()) {
+ mentionRendererDelegate = new MentionRendererDelegate(getContext());
+ }
+ }
+
+ private void doAfterCursorChange() {
+ Editable text = getText();
+ if (text != null && enoughToFilter(text)) {
+ performFiltering(text);
+ } else {
+ updateQuery("");
+ }
+ }
+
+ private void performFiltering(@NonNull Editable text) {
+ int end = getSelectionEnd();
+ int start = findQueryStart(text, end);
+ CharSequence query = text.subSequence(start, end);
+ updateQuery(query);
+ }
+
+ private void updateQuery(@NonNull CharSequence query) {
+ if (mentionQueryChangedListener != null) {
+ mentionQueryChangedListener.onQueryChanged(query);
+ }
+ }
+
+ private boolean enoughToFilter(@NonNull Editable text) {
+ int end = getSelectionEnd();
+ if (end < 0) {
+ return false;
+ }
+ return end - findQueryStart(text, end) >= 1;
+ }
+
+ public void replaceTextWithMention(@NonNull String displayName, @NonNull UUID uuid) {
+ Editable text = getText();
+ if (text == null) {
+ return;
+ }
+
+ clearComposingText();
+
+ 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));
+ }
+
+ private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) {
+ SpannableStringBuilder builder = new SpannableStringBuilder("@");
+ if (text instanceof Spanned) {
+ SpannableString spannableString = new SpannableString(text + " ");
+ TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
+ builder.append(spannableString);
+ } else {
+ builder.append(text).append(" ");
+ }
+
+ builder.setSpan(MentionAnnotation.mentionAnnotationForUuid(uuid), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ return builder;
+ }
+
+ private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) {
+ if (inputCursorPosition == 0) {
+ return inputCursorPosition;
+ }
+
+ int delimiterSearchIndex = inputCursorPosition - 1;
+ while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != '@' && text.charAt(delimiterSearchIndex) != ' ')) {
+ delimiterSearchIndex--;
+ }
+
+ if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') {
+ return delimiterSearchIndex + 1;
+ }
+ return inputCursorPosition;
}
- @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = CommitContentListener.class.getSimpleName();
@@ -207,4 +323,8 @@ public class ComposeText extends EmojiEditText {
public interface CursorPositionChangedListener {
void onCursorPositionChanged(int start, int end);
}
+
+ public interface MentionQueryChangedListener {
+ void onQueryChanged(CharSequence query);
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java
new file mode 100644
index 0000000000..ed7af5a7a1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java
@@ -0,0 +1,26 @@
+package org.thoughtcrime.securesms.components.mention;
+
+
+import android.text.Annotation;
+
+import androidx.annotation.NonNull;
+
+import java.util.UUID;
+
+/**
+ * Factory for creating mention annotation spans.
+ *
+ * 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.
+ */
+public final class MentionAnnotation {
+
+ public static final String MENTION_ANNOTATION = "mention";
+
+ private MentionAnnotation() {
+ }
+
+ public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) {
+ return new Annotation(MENTION_ANNOTATION, uuid.toString());
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRenderer.java
new file mode 100644
index 0000000000..1c6ba77d48
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRenderer.java
@@ -0,0 +1,133 @@
+package org.thoughtcrime.securesms.components.mention;
+
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.text.Layout;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.util.LayoutUtil;
+
+/**
+ * Handles actually drawing the mention backgrounds for a TextView.
+ *
+ * Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
+ */
+public abstract class MentionRenderer {
+
+ protected final int horizontalPadding;
+ protected final int verticalPadding;
+
+ public MentionRenderer(int horizontalPadding, int verticalPadding) {
+ this.horizontalPadding = horizontalPadding;
+ this.verticalPadding = verticalPadding;
+ }
+
+ public abstract void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset);
+
+ protected int getLineTop(@NonNull Layout layout, int line) {
+ return LayoutUtil.getLineTopWithoutPadding(layout, line) - verticalPadding;
+ }
+
+ protected int getLineBottom(@NonNull Layout layout, int line) {
+ return LayoutUtil.getLineBottomWithoutPadding(layout, line) + verticalPadding;
+ }
+
+ public static final class SingleLineMentionRenderer extends MentionRenderer {
+
+ private final Drawable drawable;
+
+ public SingleLineMentionRenderer(int horizontalPadding, int verticalPadding, @NonNull Drawable drawable) {
+ super(horizontalPadding, verticalPadding);
+ this.drawable = drawable;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
+ int lineTop = getLineTop(layout, startLine);
+ int lineBottom = getLineBottom(layout, startLine);
+ int left = Math.min(startOffset, endOffset);
+ int right = Math.max(startOffset, endOffset);
+
+ drawable.setBounds(left, lineTop, right, lineBottom);
+ drawable.draw(canvas);
+ }
+ }
+
+ public static final class MultiLineMentionRenderer extends MentionRenderer {
+
+ private final Drawable drawableLeft;
+ private final Drawable drawableMid;
+ private final Drawable drawableRight;
+
+ public MultiLineMentionRenderer(int horizontalPadding, int verticalPadding,
+ @NonNull Drawable drawableLeft,
+ @NonNull Drawable drawableMid,
+ @NonNull Drawable drawableRight)
+ {
+ super(horizontalPadding, verticalPadding);
+ this.drawableLeft = drawableLeft;
+ this.drawableMid = drawableMid;
+ this.drawableRight = drawableRight;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
+ int paragraphDirection = layout.getParagraphDirection(startLine);
+
+ float lineEndOffset;
+ if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
+ lineEndOffset = layout.getLineLeft(startLine) - horizontalPadding;
+ } else {
+ lineEndOffset = layout.getLineRight(startLine) + horizontalPadding;
+ }
+
+ int lineBottom = getLineBottom(layout, startLine);
+ int lineTop = getLineTop(layout, startLine);
+ drawStart(canvas, startOffset, lineTop, (int) lineEndOffset, lineBottom);
+
+ for (int line = startLine + 1; line < endLine; line++) {
+ int left = (int) layout.getLineLeft(line) - horizontalPadding;
+ int right = (int) layout.getLineRight(line) + horizontalPadding;
+
+ lineTop = getLineTop(layout, line);
+ lineBottom = getLineBottom(layout, line);
+
+ drawableMid.setBounds(left, lineTop, right, lineBottom);
+ drawableMid.draw(canvas);
+ }
+
+ float lineStartOffset;
+ if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
+ lineStartOffset = layout.getLineRight(startLine) + horizontalPadding;
+ } else {
+ lineStartOffset = layout.getLineLeft(startLine) - horizontalPadding;
+ }
+
+ lineBottom = getLineBottom(layout, endLine);
+ lineTop = getLineTop(layout, endLine);
+
+ drawEnd(canvas, (int) lineStartOffset, lineTop, endOffset, lineBottom);
+ }
+
+ private void drawStart(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
+ if (start > end) {
+ drawableRight.setBounds(end, top, start, bottom);
+ drawableRight.draw(canvas);
+ } else {
+ drawableLeft.setBounds(start, top, end, bottom);
+ drawableLeft.draw(canvas);
+ }
+ }
+
+ private void drawEnd(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
+ if (start > end) {
+ drawableLeft.setBounds(end, top, start, bottom);
+ drawableLeft.draw(canvas);
+ } else {
+ drawableRight.setBounds(start, top, end, bottom);
+ drawableRight.draw(canvas);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java
new file mode 100644
index 0000000000..69f50f55df
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java
@@ -0,0 +1,78 @@
+package org.thoughtcrime.securesms.components.mention;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.text.Annotation;
+import android.text.Layout;
+import android.text.Spanned;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+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;
+
+/**
+ * Encapsulates the logic for determining the type of mention rendering needed (single vs multi-line) and then
+ * passing that information to the appropriate {@link MentionRenderer}.
+ *
+ * Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
+ */
+public class MentionRendererDelegate {
+
+ private final MentionRenderer single;
+ 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(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));
+ }
+
+ 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);
+ int startLine = layout.getLineForOffset(spanStart);
+ int endLine = layout.getLineForOffset(spanEnd);
+
+ int startOffset = (int) (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine) * horizontalPadding);
+ int endOffset = (int) (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine) * horizontalPadding);
+
+ MentionRenderer renderer = (startLine == endLine) ? single : multi;
+ renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java
index 815a512eee..320557c5de 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java
@@ -125,6 +125,7 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
+import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -226,6 +227,7 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
@@ -341,6 +343,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private InputPanel inputPanel;
private View panelParent;
private View noLongerMemberBanner;
+ private Stub mentionsSuggestions;
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
@@ -414,6 +417,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
initializeStickerObserver();
initializeViewModel();
initializeGroupViewModel();
+ if (FeatureFlags.mentions()) initializeMentionsViewModel();
initializeEnabledCheck();
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener() {
@Override
@@ -1700,6 +1704,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar);
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
+ mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
@@ -1864,6 +1869,28 @@ public class ConversationActivity extends PassphraseRequiredActivity
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
}
+ private void initializeMentionsViewModel() {
+ MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class);
+
+ recipient.observe(this, mentionsViewModel::onRecipientChange);
+ composeText.setMentionQueryChangedListener(query -> {
+ if (getRecipient().isGroup()) {
+ if (!mentionsSuggestions.resolved()) {
+ mentionsSuggestions.get();
+ }
+ mentionsViewModel.onQueryChange(query);
+ }
+ });
+
+ mentionsViewModel.getSelectedRecipient().observe(this, recipient -> {
+ String replacementDisplayName = recipient.getDisplayName(this);
+ if (replacementDisplayName.equals(recipient.getDisplayUsername())) {
+ replacementDisplayName = recipient.getUsername().or(replacementDisplayName);
+ }
+ composeText.replaceTextWithMention(replacementDisplayName, recipient.requireUuid());
+ });
+ }
+
private void showStickerIntroductionTooltip() {
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
inputPanel.setMediaKeyboardToggleMode(true);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java
new file mode 100644
index 0000000000..170c9d4435
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java
@@ -0,0 +1,47 @@
+package org.thoughtcrime.securesms.conversation.ui.mentions;
+
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.AvatarImageView;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.MappingAdapter;
+import org.thoughtcrime.securesms.util.MappingViewHolder;
+
+public class MentionViewHolder extends MappingViewHolder {
+
+ private final AvatarImageView avatar;
+ private final TextView name;
+ @Nullable private final MentionEventsListener mentionEventsListener;
+
+ public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) {
+ super(itemView);
+ this.mentionEventsListener = mentionEventsListener;
+
+ avatar = findViewById(R.id.mention_recipient_avatar);
+ name = findViewById(R.id.mention_recipient_name);
+ }
+
+ @Override
+ public void bind(@NonNull MentionViewState model) {
+ avatar.setRecipient(model.getRecipient());
+ name.setText(model.getName(context));
+ itemView.setOnClickListener(v -> {
+ if (mentionEventsListener != null) {
+ mentionEventsListener.onMentionClicked(model.getRecipient());
+ }
+ });
+ }
+
+ public interface MentionEventsListener {
+ void onMentionClicked(@NonNull Recipient recipient);
+ }
+
+ public static MappingAdapter.Factory createFactory(@Nullable MentionEventsListener mentionEventsListener) {
+ return new MappingAdapter.LayoutFactory<>(view -> new MentionViewHolder(view, mentionEventsListener), R.layout.mentions_recipient_list_item);
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java
new file mode 100644
index 0000000000..52778749da
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java
@@ -0,0 +1,40 @@
+package org.thoughtcrime.securesms.conversation.ui.mentions;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.MappingModel;
+
+import java.util.Objects;
+
+public final class MentionViewState implements MappingModel {
+
+ private final Recipient recipient;
+
+ public MentionViewState(@NonNull Recipient recipient) {
+ this.recipient = recipient;
+ }
+
+ @NonNull String getName(@NonNull Context context) {
+ return recipient.getDisplayName(context);
+ }
+
+ @NonNull Recipient getRecipient() {
+ return recipient;
+ }
+
+ @Override
+ public boolean areItemsTheSame(@NonNull MentionViewState newItem) {
+ return recipient.getId().equals(newItem.recipient.getId());
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull MentionViewState newItem) {
+ Context context = ApplicationDependencies.getApplication();
+ return recipient.getDisplayName(context).equals(newItem.recipient.getDisplayName(context)) &&
+ Objects.equals(recipient.getProfileAvatar(), newItem.recipient.getProfileAvatar());
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java
new file mode 100644
index 0000000000..37243a0b0f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java
@@ -0,0 +1,12 @@
+package org.thoughtcrime.securesms.conversation.ui.mentions;
+
+import androidx.annotation.Nullable;
+
+import org.thoughtcrime.securesms.conversation.ui.mentions.MentionViewHolder.MentionEventsListener;
+import org.thoughtcrime.securesms.util.MappingAdapter;
+
+public class MentionsPickerAdapter extends MappingAdapter {
+ public MentionsPickerAdapter(@Nullable MentionEventsListener mentionEventsListener) {
+ registerFactory(MentionViewState.class, MentionViewHolder.createFactory(mentionEventsListener));
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java
new file mode 100644
index 0000000000..8ec090af3c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java
@@ -0,0 +1,88 @@
+package org.thoughtcrime.securesms.conversation.ui.mentions;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.bottomsheet.BottomSheetBehavior;
+
+import org.thoughtcrime.securesms.LoggingFragment;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.MappingModel;
+import org.thoughtcrime.securesms.util.ViewUtil;
+
+import java.util.List;
+
+public class MentionsPickerFragment extends LoggingFragment {
+
+ private MentionsPickerAdapter adapter;
+ private RecyclerView list;
+ private BottomSheetBehavior behavior;
+ private MentionsPickerViewModel viewModel;
+
+ @Override
+ public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false);
+
+ list = view.findViewById(R.id.mentions_picker_list);
+ behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet));
+
+ return view;
+ }
+
+ @Override
+ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ initializeList();
+
+ viewModel = ViewModelProviders.of(requireActivity()).get(MentionsPickerViewModel.class);
+ viewModel.getMentionList().observe(getViewLifecycleOwner(), this::updateList);
+ }
+
+ private void initializeList() {
+ adapter = new MentionsPickerAdapter(this::handleMentionClicked);
+
+ RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(requireContext()) {
+ @Override
+ public void onLayoutCompleted(RecyclerView.State state) {
+ super.onLayoutCompleted(state);
+ updateBottomSheetBehavior(adapter.getItemCount());
+ }
+ };
+
+ list.setLayoutManager(layoutManager);
+ list.setAdapter(adapter);
+ list.setItemAnimator(null);
+ }
+
+ private void handleMentionClicked(@NonNull Recipient recipient) {
+ viewModel.onSelectionChange(recipient);
+ }
+
+ private void updateList(@NonNull List> mappingModels) {
+ adapter.submitList(mappingModels);
+ if (mappingModels.isEmpty()) {
+ updateBottomSheetBehavior(0);
+ }
+ }
+
+ private void updateBottomSheetBehavior(int count) {
+ if (count > 0) {
+ if (behavior.getPeekHeight() == 0) {
+ behavior.setPeekHeight(ViewUtil.dpToPx(240), true);
+ behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
+ }
+ } else {
+ behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
+ behavior.setPeekHeight(0);
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java
new file mode 100644
index 0000000000..a92c9d10da
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java
@@ -0,0 +1,86 @@
+package org.thoughtcrime.securesms.conversation.ui.mentions;
+
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Transformations;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.annimon.stream.Stream;
+
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.groups.GroupId;
+import org.thoughtcrime.securesms.groups.LiveGroup;
+import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry.FullMember;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.MappingModel;
+import org.thoughtcrime.securesms.util.SingleLiveEvent;
+import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
+
+import java.util.Collections;
+import java.util.List;
+
+public class MentionsPickerViewModel extends ViewModel {
+
+ private final SingleLiveEvent selectedRecipient;
+ private final LiveData>> mentionList;
+ private final MutableLiveData group;
+ private final MutableLiveData liveQuery;
+
+ MentionsPickerViewModel() {
+ group = new MutableLiveData<>();
+ liveQuery = new MutableLiveData<>();
+ selectedRecipient = new SingleLiveEvent<>();
+
+ // TODO [cody] [mentions] simple query support implement for building UI/UX, to be replaced with better search before launch
+ LiveData> members = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers));
+
+ mentionList = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(liveQuery), members, this::filterMembers);
+ }
+
+ @NonNull LiveData>> getMentionList() {
+ return mentionList;
+ }
+
+ void onSelectionChange(@NonNull Recipient recipient) {
+ selectedRecipient.setValue(recipient);
+ }
+
+ public @NonNull LiveData getSelectedRecipient() {
+ return selectedRecipient;
+ }
+
+ public void onQueryChange(@NonNull CharSequence query) {
+ liveQuery.setValue(query);
+ }
+
+ public void onRecipientChange(@NonNull Recipient recipient) {
+ GroupId groupId = recipient.getGroupId().orNull();
+ if (groupId != null) {
+ LiveGroup liveGroup = new LiveGroup(groupId);
+ group.setValue(liveGroup);
+ }
+ }
+
+ private @NonNull List> filterMembers(@NonNull CharSequence query, @NonNull List members) {
+ if (TextUtils.isEmpty(query)) {
+ return Collections.emptyList();
+ }
+
+ return Stream.of(members)
+ .filter(m -> m.getMember().getDisplayName(ApplicationDependencies.getApplication()).toLowerCase().replaceAll("\\s", "").startsWith(query.toString()))
+ .>map(m -> new MentionViewState(m.getMember()))
+ .toList();
+ }
+
+ public static final class Factory implements ViewModelProvider.Factory {
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ //noinspection ConstantConditions
+ return modelClass.cast(new MentionsPickerViewModel());
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
index ec0dd2ab92..4eb70747bb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java
@@ -784,7 +784,7 @@ public class Recipient {
return ApplicationDependencies.getRecipientCache().getLive(id);
}
- private @Nullable String getDisplayUsername() {
+ public @Nullable String getDisplayUsername() {
if (!TextUtils.isEmpty(username)) {
return "@" + username;
} else {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java
index f03b27e3c5..78e653e98f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java
@@ -4,7 +4,9 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
+import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
+import androidx.core.graphics.drawable.DrawableCompat;
public final class DrawableUtil {
@@ -19,4 +21,13 @@ public final class DrawableUtil {
return bitmap;
}
+
+ /**
+ * Returns a new {@link Drawable} that safely wraps and tints the provided drawable.
+ */
+ public static @NonNull Drawable tint(@NonNull Drawable drawable, @ColorInt int tint) {
+ Drawable tinted = DrawableCompat.wrap(drawable).mutate();
+ DrawableCompat.setTint(tinted, tint);
+ return tinted;
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
index cfefc839fe..ec53f2096a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -58,6 +58,7 @@ public final class FeatureFlags {
private static final String CDS = "android.cds";
private static final String RECIPIENT_TRUST = "android.recipientTrust";
private static final String INTERNAL_USER = "android.internalUser";
+ private static final String MENTIONS = "android.mentions";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -71,7 +72,8 @@ public final class FeatureFlags {
GROUPS_V2_CREATE,
GROUPS_V2_CAPACITY,
RECIPIENT_TRUST,
- INTERNAL_USER
+ INTERNAL_USER,
+ MENTIONS
);
/**
@@ -221,6 +223,11 @@ public final class FeatureFlags {
return getBoolean(RECIPIENT_TRUST, false);
}
+ /** Whether or not we allow mentions send support in groups. */
+ public static boolean mentions() {
+ return getBoolean(MENTIONS, false);
+ }
+
/** Only for rendering debug info. */
public static synchronized @NonNull Map getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LayoutUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/LayoutUtil.java
new file mode 100644
index 0000000000..5ee60408f5
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/LayoutUtil.java
@@ -0,0 +1,61 @@
+package org.thoughtcrime.securesms.util;
+
+import android.text.Layout;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Utility functions for dealing with {@link Layout}.
+ *
+ * Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
+ */
+public class LayoutUtil {
+ private static final float DEFAULT_LINE_SPACING_EXTRA = 0f;
+
+ private static final float DEFAULT_LINE_SPACING_MULTIPLIER = 1f;
+
+ public static int getLineHeight(@NonNull Layout layout, int line) {
+ return layout.getLineTop(line + 1) - layout.getLineTop(line);
+ }
+
+ public static int getLineTopWithoutPadding(@NonNull Layout layout, int line) {
+ int lineTop = layout.getLineTop(line);
+ if (line == 0) {
+ lineTop -= layout.getTopPadding();
+ }
+ return lineTop;
+ }
+
+ public static int getLineBottomWithoutPadding(@NonNull Layout layout, int line) {
+ int lineBottom = getLineBottomWithoutSpacing(layout, line);
+ if (line == layout.getLineCount() - 1) {
+ lineBottom -= layout.getBottomPadding();
+ }
+ return lineBottom;
+ }
+
+ public static int getLineBottomWithoutSpacing(@NonNull Layout layout, int line) {
+ int lineBottom = layout.getLineBottom(line);
+ boolean isLastLine = line == layout.getLineCount() - 1;
+ float lineSpacingExtra = layout.getSpacingAdd();
+ float lineSpacingMultiplier = layout.getSpacingMultiplier();
+ boolean hasLineSpacing = lineSpacingExtra != DEFAULT_LINE_SPACING_EXTRA || lineSpacingMultiplier != DEFAULT_LINE_SPACING_MULTIPLIER;
+
+ int lineBottomWithoutSpacing;
+ if (!hasLineSpacing || isLastLine) {
+ lineBottomWithoutSpacing = lineBottom;
+ } else {
+ float extra;
+ if (Float.compare(lineSpacingMultiplier, DEFAULT_LINE_SPACING_MULTIPLIER) != 0) {
+ int lineHeight = getLineHeight(layout, line);
+ extra = lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier;
+ } else {
+ extra = lineSpacingExtra;
+ }
+
+ lineBottomWithoutSpacing = (int) (lineBottom - extra);
+ }
+
+ return lineBottomWithoutSpacing;
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java
new file mode 100644
index 0000000000..07a250c29d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java
@@ -0,0 +1,133 @@
+package org.thoughtcrime.securesms.util;
+
+import android.annotation.SuppressLint;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListAdapter;
+
+import org.whispersystems.libsignal.util.guava.Function;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A reusable and composable {@link androidx.recyclerview.widget.RecyclerView.Adapter} built on-top of {@link ListAdapter} to
+ * provide async item diffing support.
+ *
+ * The adapter makes use of mapping a model class to view holder factory at runtime via one of the {@link #registerFactory(Class, Factory)}
+ * methods. The factory creates a view holder specifically designed to handle the paired model type. This allows the view holder concretely
+ * deal with the model type it cares about. Due to the enforcement of matching generics during factory registration we can safely ignore or
+ * override compiler typing recommendations when binding and diffing.
+ *
+ * General pattern for implementation:
+ *
+ * - Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.
+ * - Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.
+ * - Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.
+ *
+ * Event listeners, click or otherwise, are handled at the view holder level and should be passed into the appropriate view holder factories. This
+ * pattern mimics how we pass data into view models via factories.
+ *
+ * NOTE: There can only be on factory registered per model type. Registering two for the same type will result in the last one being used. However, the
+ * same factory can be registered multiple times for multiple model types (if the model type class hierarchy supports it).
+ */
+public class MappingAdapter extends ListAdapter, MappingViewHolder>> {
+
+ private final Map> factories;
+ private final Map, Integer> itemTypes;
+ private int typeCount;
+
+ public MappingAdapter() {
+ super(new MappingDiffCallback());
+
+ factories = new HashMap<>();
+ itemTypes = new HashMap<>();
+ typeCount = 0;
+ }
+
+ @Override
+ public void onViewAttachedToWindow(@NonNull MappingViewHolder> holder) {
+ super.onViewAttachedToWindow(holder);
+ holder.onAttachedToWindow();
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(@NonNull MappingViewHolder> holder) {
+ super.onViewDetachedFromWindow(holder);
+ holder.onDetachedFromWindow();
+ }
+
+ public > void registerFactory(Class clazz, Factory factory) {
+ int type = typeCount++;
+ factories.put(type, factory);
+ itemTypes.put(clazz, type);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ Integer type = itemTypes.get(getItem(position).getClass());
+ if (type != null) {
+ return type;
+ }
+ throw new AssertionError("No view holder factory for type: " + getItem(position).getClass());
+ }
+
+ @Override
+ public @NonNull MappingViewHolder> onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return Objects.requireNonNull(factories.get(viewType)).createViewHolder(parent);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull MappingViewHolder holder, int position) {
+ //noinspection unchecked
+ holder.bind(getItem(position));
+ }
+
+ private static class MappingDiffCallback extends DiffUtil.ItemCallback> {
+ @Override
+ public boolean areItemsTheSame(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) {
+ if (oldItem.getClass() == newItem.getClass()) {
+ //noinspection unchecked
+ return oldItem.areItemsTheSame(newItem);
+ }
+ return false;
+ }
+
+ @SuppressLint("DiffUtilEquals")
+ @Override
+ public boolean areContentsTheSame(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) {
+ if (oldItem.getClass() == newItem.getClass()) {
+ //noinspection unchecked
+ return oldItem.areContentsTheSame(newItem);
+ }
+ return false;
+ }
+ }
+
+ public interface Factory> {
+ @NonNull MappingViewHolder createViewHolder(ViewGroup parent);
+ }
+
+ public static class LayoutFactory> implements Factory {
+ private Function> creator;
+ private final int layout;
+
+ public LayoutFactory(Function> creator, @LayoutRes int layout) {
+ this.creator = creator;
+ this.layout = layout;
+ }
+
+ @Override
+ public @NonNull MappingViewHolder createViewHolder(ViewGroup parent) {
+ return creator.apply(LayoutInflater.from(parent.getContext()).inflate(layout, parent, false));
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java
new file mode 100644
index 0000000000..0e5233d178
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingModel.java
@@ -0,0 +1,8 @@
+package org.thoughtcrime.securesms.util;
+
+import androidx.annotation.NonNull;
+
+public interface MappingModel {
+ boolean areItemsTheSame(@NonNull T newItem);
+ boolean areContentsTheSame(@NonNull T newItem);
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java
new file mode 100644
index 0000000000..13ae30abc1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java
@@ -0,0 +1,27 @@
+package org.thoughtcrime.securesms.util;
+
+import android.content.Context;
+import android.view.View;
+
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LifecycleRegistry;
+import androidx.recyclerview.widget.RecyclerView;
+
+public abstract class MappingViewHolder> extends LifecycleViewHolder implements LifecycleOwner {
+
+ protected final Context context;
+
+ public MappingViewHolder(@NonNull View itemView) {
+ super(itemView);
+ context = itemView.getContext();
+ }
+
+ public T findViewById(@IdRes int id) {
+ return itemView.findViewById(id);
+ }
+
+ public abstract void bind(@NonNull Model model);
+}
diff --git a/app/src/main/res/drawable/mention_text_bg.xml b/app/src/main/res/drawable/mention_text_bg.xml
new file mode 100644
index 0000000000..215a40b8fd
--- /dev/null
+++ b/app/src/main/res/drawable/mention_text_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/mention_text_bg_left.xml b/app/src/main/res/drawable/mention_text_bg_left.xml
new file mode 100644
index 0000000000..180c03c564
--- /dev/null
+++ b/app/src/main/res/drawable/mention_text_bg_left.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/mention_text_bg_mid.xml b/app/src/main/res/drawable/mention_text_bg_mid.xml
new file mode 100644
index 0000000000..7af835eab3
--- /dev/null
+++ b/app/src/main/res/drawable/mention_text_bg_mid.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/mention_text_bg_right.xml b/app/src/main/res/drawable/mention_text_bg_right.xml
new file mode 100644
index 0000000000..ff29dd5f14
--- /dev/null
+++ b/app/src/main/res/drawable/mention_text_bg_right.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml
index 614b0705c2..b5e1a80da9 100644
--- a/app/src/main/res/layout/conversation_activity.xml
+++ b/app/src/main/res/layout/conversation_activity.xml
@@ -64,10 +64,22 @@
android:layout="@layout/conversation_activity_reminderview_stub" />
+ android:layout_weight="1">
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/mentions_picker_fragment.xml b/app/src/main/res/layout/mentions_picker_fragment.xml
new file mode 100644
index 0000000000..d467e84ead
--- /dev/null
+++ b/app/src/main/res/layout/mentions_picker_fragment.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/mentions_recipient_list_item.xml b/app/src/main/res/layout/mentions_recipient_list_item.xml
new file mode 100644
index 0000000000..b2140ad7b4
--- /dev/null
+++ b/app/src/main/res/layout/mentions_recipient_list_item.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index 52008cf5c8..68ce3da0a2 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -86,6 +86,7 @@
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 360c9c4cfe..887d12cef0 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -78,6 +78,8 @@
18dp
60dp
+ 4dp
+
3
8dp
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index f81356a7ae..ca306c7f72 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -259,6 +259,7 @@
- @style/ThemeOverlay.AppCompat.Light
- @color/white
- @color/transparent_white_90
+ - @color/core_grey_20
- @color/core_grey_05
- @color/core_ultramarine
@@ -610,6 +611,7 @@
- @style/ThemeOverlay.AppCompat.Dark
- @color/transparent_white_90
- @color/transparent_white_80
+ - @color/core_grey_75
- @drawable/scroll_to_bottom_background_dark
- @color/core_white