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 c711bb541..923069c7f 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 000000000..ed7af5a7a --- /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 000000000..1c6ba77d4 --- /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 000000000..69f50f55d --- /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 815a512ee..320557c5d 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 000000000..170c9d443 --- /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 000000000..52778749d --- /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 000000000..37243a0b0 --- /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 000000000..8ec090af3 --- /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 000000000..a92c9d10d --- /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 ec0dd2ab9..4eb70747b 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 f03b27e3c..78e653e98 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 cfefc839f..ec53f2096 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 000000000..5ee60408f --- /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 000000000..07a250c29 --- /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: + *
    + *
  1. Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.
  2. + *
  3. Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.
  4. + *
  5. Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.
  6. + *
+ * 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 000000000..0e5233d17 --- /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 000000000..13ae30abc --- /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 000000000..215a40b8f --- /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 000000000..180c03c56 --- /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 000000000..7af835eab --- /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 000000000..ff29dd5f1 --- /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 614b0705c..b5e1a80da 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 000000000..d467e84ea --- /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 000000000..b2140ad7b --- /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 52008cf5c..68ce3da0a 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 360c9c4cf..887d12cef 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 f81356a7a..ca306c7f7 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