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 1c654f5d17..beaefa8bb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -26,6 +26,7 @@ import androidx.core.view.inputmethod.EditorInfoCompat; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; +import org.signal.core.util.StringUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.EmojiEditText; @@ -34,19 +35,25 @@ import org.thoughtcrime.securesms.components.mention.MentionDeleter; import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher; import org.thoughtcrime.securesms.conversation.MessageSendType; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.signal.core.util.StringUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.List; import java.util.Objects; +import java.util.concurrent.TimeUnit; import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; public class ComposeText extends EmojiEditText { + private static final char EMOJI_STARTER = ':'; + private static final long EMOJI_KEYWORD_DELAY = TimeUnit.SECONDS.toMillis(1); + private CharSequence hint; private SpannableString subHint; private MentionRendererDelegate mentionRendererDelegate; @@ -54,7 +61,14 @@ public class ComposeText extends EmojiEditText { @Nullable private InputPanel.MediaListener mediaListener; @Nullable private CursorPositionChangedListener cursorPositionChangedListener; - @Nullable private MentionQueryChangedListener mentionQueryChangedListener; + @Nullable private InlineQueryChangedListener inlineQueryChangedListener; + + private final Runnable keywordSearchRunnable = () -> { + Editable text = getText(); + if (text != null && enoughToFilter(text, true)) { + performFiltering(text, true); + } + }; public ComposeText(Context context) { super(context); @@ -111,7 +125,7 @@ public class ComposeText extends EmojiEditText { if (selectionStart == selectionEnd) { doAfterCursorChange(getText()); } else { - updateQuery(null); + clearInlineQuery(); } } @@ -189,8 +203,8 @@ public class ComposeText extends EmojiEditText { this.cursorPositionChangedListener = listener; } - public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) { - this.mentionQueryChangedListener = listener; + public void setInlineQueryChangedListener(@Nullable InlineQueryChangedListener listener) { + this.inlineQueryChangedListener = listener; } public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) { @@ -226,15 +240,23 @@ public class ComposeText extends EmojiEditText { public InputConnection onCreateInputConnection(EditorInfo editorInfo) { InputConnection inputConnection = super.onCreateInputConnection(editorInfo); - if(SignalStore.settings().isEnterKeySends()) { + if (SignalStore.settings().isEnterKeySends()) { editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; } - if (Build.VERSION.SDK_INT < 21) return inputConnection; - if (mediaListener == null) return inputConnection; - if (inputConnection == null) return null; + if (Build.VERSION.SDK_INT < 21) { + return inputConnection; + } - EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"}); + if (mediaListener == null) { + return inputConnection; + } + + if (inputConnection == null) { + return null; + } + + EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif" }); return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); } @@ -300,35 +322,53 @@ public class ComposeText extends EmojiEditText { } private void doAfterCursorChange(@NonNull Editable text) { - if (enoughToFilter(text)) { - performFiltering(text); + removeCallbacks(keywordSearchRunnable); + if (enoughToFilter(text, false)) { + performFiltering(text, false); } else { - updateQuery(null); + postDelayed(keywordSearchRunnable, EMOJI_KEYWORD_DELAY); + clearInlineQuery(); } } - private void performFiltering(@NonNull Editable text) { - int end = getSelectionEnd(); - int start = findQueryStart(text, end); - CharSequence query = text.subSequence(start, end); - updateQuery(query.toString()); - } + private void performFiltering(@NonNull Editable text, boolean keywordEmojiSearch) { + int end = getSelectionEnd(); + QueryStart queryStart = findQueryStart(text, end, keywordEmojiSearch); + int start = queryStart.index; + String query = text.subSequence(start, end).toString(); - private void updateQuery(@Nullable String query) { - if (mentionQueryChangedListener != null) { - mentionQueryChangedListener.onQueryChanged(query); + if (inlineQueryChangedListener != null) { + if (queryStart.isMentionQuery) { + inlineQueryChangedListener.onQueryChanged(new InlineQuery.Mention(query)); + } else { + inlineQueryChangedListener.onQueryChanged(new InlineQuery.Emoji(query, keywordEmojiSearch)); + } } } - private boolean enoughToFilter(@NonNull Editable text) { + private void clearInlineQuery() { + if (inlineQueryChangedListener != null) { + inlineQueryChangedListener.clearQuery(); + } + } + + private boolean enoughToFilter(@NonNull Editable text, boolean keywordEmojiSearch) { int end = getSelectionEnd(); if (end < 0) { return false; } - return findQueryStart(text, end) != -1; + return findQueryStart(text, end, keywordEmojiSearch).index != -1; } public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) { + replaceText(createReplacementToken(displayName, recipientId), false); + } + + public void replaceText(@NonNull InlineQueryReplacement replacement) { + replaceText(replacement.toCharSequence(getContext()), replacement.isKeywordSearch()); + } + + private void replaceText(@NonNull CharSequence replacement, boolean keywordReplacement) { Editable text = getText(); if (text == null) { return; @@ -336,10 +376,11 @@ public class ComposeText extends EmojiEditText { clearComposingText(); - int end = getSelectionEnd(); - int start = findQueryStart(text, end) - 1; + int end = getSelectionEnd(); + int start = findQueryStart(text, end, keywordReplacement).index - (keywordReplacement ? 0 : 1); - text.replace(start, end, createReplacementToken(displayName, recipientId)); + text.replace(start, end, ""); + text.insert(start, replacement); } private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) { @@ -357,17 +398,37 @@ public class ComposeText extends EmojiEditText { return builder; } - private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) { + private QueryStart findQueryStart(@NonNull CharSequence text, int inputCursorPosition, boolean keywordEmojiSearch) { + if (keywordEmojiSearch) { + int start = findQueryStart(text, inputCursorPosition, ' '); + if (start == -1 && inputCursorPosition != 0) { + start = 0; + } else if (start == inputCursorPosition) { + start = -1; + } + return new QueryStart(start, false); + } + + QueryStart queryStart = new QueryStart(findQueryStart(text, inputCursorPosition, MENTION_STARTER), true); + + if (queryStart.index < 0) { + queryStart = new QueryStart(findQueryStart(text, inputCursorPosition, EMOJI_STARTER), false); + } + + return queryStart; + } + + private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition, char starter) { if (inputCursorPosition == 0) { return -1; } int delimiterSearchIndex = inputCursorPosition - 1; - while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) { + while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != starter && text.charAt(delimiterSearchIndex) != ' ')) { delimiterSearchIndex--; } - if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) { + if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == starter) { return delimiterSearchIndex + 1; } return -1; @@ -405,11 +466,18 @@ public class ComposeText extends EmojiEditText { } } + private static class QueryStart { + public int index; + public boolean isMentionQuery; + + public QueryStart(int index, boolean isMentionQuery) { + this.index = index; + this.isMentionQuery = isMentionQuery; + } + } + public interface CursorPositionChangedListener { void onCursorPositionChanged(int start, int end); } - public interface MentionQueryChangedListener { - void onQueryChanged(@Nullable String query); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java index 3ab7894e7e..9acdf3c809 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java @@ -10,14 +10,17 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatEditText; -import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import java.util.HashSet; +import java.util.Set; + public class EmojiEditText extends AppCompatEditText { - private static final String TAG = Log.tag(EmojiEditText.class); + + private final Set onFocusChangeListeners = new HashSet<>(); public EmojiEditText(Context context) { this(context, null); @@ -38,6 +41,12 @@ public class EmojiEditText extends AppCompatEditText { if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) { setFilters(appendEmojiFilter(this.getFilters(), jumboEmoji)); } + + super.setOnFocusChangeListener((v, hasFocus) -> { + for (OnFocusChangeListener listener : onFocusChangeListeners) { + listener.onFocusChange(v, hasFocus); + } + }); } public void insertEmoji(String emoji) { @@ -54,6 +63,17 @@ public class EmojiEditText extends AppCompatEditText { else super.invalidateDrawable(drawable); } + @Override + public void setOnFocusChangeListener(@Nullable OnFocusChangeListener listener) { + if (listener != null) { + onFocusChangeListeners.add(listener); + } + } + + public void addOnFocusChangeListener(@NonNull OnFocusChangeListener listener) { + onFocusChangeListeners.add(listener); + } + private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) { InputFilter[] result; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt index 4186501260..16ef2c7911 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalContextMenu.kt @@ -133,11 +133,11 @@ class SignalContextMenu private constructor( val container: ViewGroup ) { - var onDismiss: Runnable? = null - var offsetX = 0 - var offsetY = 0 - var horizontalPosition = HorizontalPosition.START - var verticalPosition = VerticalPosition.BELOW + private var onDismiss: Runnable? = null + private var offsetX = 0 + private var offsetY = 0 + private var horizontalPosition = HorizontalPosition.START + private var verticalPosition = VerticalPosition.BELOW fun onDismiss(onDismiss: Runnable): Builder { this.onDismiss = onDismiss diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index e3fc29b148..22126255a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -103,6 +103,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.DimensionUnit; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; @@ -137,6 +138,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.components.menu.ActionItem; +import org.thoughtcrime.securesms.components.menu.SignalContextMenu; import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder; import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder; @@ -163,6 +166,11 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationM import org.thoughtcrime.securesms.conversation.drafts.DraftRepository; import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel; import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsPopup; +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; import org.thoughtcrime.securesms.crypto.SecurityEvent; @@ -328,8 +336,11 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.disposables.Disposable; +import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; /** @@ -443,12 +454,14 @@ public class ConversationParentFragment extends Fragment private InviteReminderModel inviteReminderModel; private ConversationGroupViewModel groupViewModel; private MentionsPickerViewModel mentionsViewModel; + private InlineQueryViewModel inlineQueryViewModel; private GroupCallViewModel groupCallViewModel; private VoiceRecorderWakeLock voiceRecorderWakeLock; private DraftViewModel draftViewModel; private VoiceNoteMediaController voiceNoteMediaController; private VoiceNotePlayerView voiceNotePlayerView; private Material3OnScrollHelper material3OnScrollHelper; + private InlineQueryResultsController inlineQueryResultsController; private LiveRecipient recipient; private long threadId; @@ -644,6 +657,10 @@ public class ConversationParentFragment extends Fragment if (reactionDelegate.isShowing()) { reactionDelegate.hide(); } + + if (inlineQueryResultsController != null) { + inlineQueryResultsController.onOrientationChange(newConfig.orientation == ORIENTATION_LANDSCAPE); + } } @Override @@ -2330,7 +2347,17 @@ public class ConversationParentFragment extends Fragment } private void initializeMentionsViewModel() { - mentionsViewModel = new ViewModelProvider(requireActivity(), new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); + mentionsViewModel = new ViewModelProvider(requireActivity(), new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); + inlineQueryViewModel = new ViewModelProvider(requireActivity()).get(InlineQueryViewModel.class); + + inlineQueryResultsController = new InlineQueryResultsController( + requireContext(), + inlineQueryViewModel, + inputPanel, + (ViewGroup) requireView(), + composeText, + getViewLifecycleOwner() + ); recipient.observe(getViewLifecycleOwner(), r -> { if (r.isPushV2Group() && !mentionsSuggestions.resolved()) { @@ -2339,12 +2366,29 @@ public class ConversationParentFragment extends Fragment mentionsViewModel.onRecipientChange(r); }); - composeText.setMentionQueryChangedListener(query -> { - if (getRecipient().isPushV2Group() && getRecipient().isActiveGroup()) { - if (!mentionsSuggestions.resolved()) { - mentionsSuggestions.get(); + composeText.setInlineQueryChangedListener(new InlineQueryChangedListener() { + @Override + public void onQueryChanged(@NonNull InlineQuery inlineQuery) { + if (inlineQuery instanceof InlineQuery.Mention) { + if (getRecipient().isPushV2Group() && getRecipient().isActiveGroup()) { + if (!mentionsSuggestions.resolved()) { + mentionsSuggestions.get(); + } + mentionsViewModel.onQueryChange(inlineQuery.getQuery()); + } + inlineQueryViewModel.onQueryChange(inlineQuery); + } else if (inlineQuery instanceof InlineQuery.Emoji) { + inlineQueryViewModel.onQueryChange(inlineQuery); + mentionsViewModel.onQueryChange(null); + } else if (inlineQuery instanceof InlineQuery.NoQuery) { + mentionsViewModel.onQueryChange(null); + inlineQueryViewModel.onQueryChange(inlineQuery); } - mentionsViewModel.onQueryChange(query); + } + + @Override + public void clearQuery() { + onQueryChanged(InlineQuery.NoQuery.INSTANCE); } }); @@ -2365,6 +2409,15 @@ public class ConversationParentFragment extends Fragment mentionsViewModel.getSelectedRecipient().observe(getViewLifecycleOwner(), recipient -> { composeText.replaceTextWithMention(recipient.getDisplayName(requireContext()), recipient.getId()); }); + + Disposable disposable = inlineQueryViewModel + .getSelection() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(r -> { + composeText.replaceText(r); + }); + + disposables.add(disposable); } public void initializeGroupCallViewModel() { @@ -3776,6 +3829,7 @@ public class ConversationParentFragment extends Fragment reactionDelegate.setOnActionSelectedListener(onActionSelectedListener); reactionDelegate.setOnHideListener(onHideListener); reactionDelegate.show(requireActivity(), recipient.get(), conversationMessage, groupViewModel.isNonAdminInAnnouncementGroup(), selectedConversationModel); + composeText.clearFocus(); if (attachmentKeyboardStub.resolved()) { attachmentKeyboardStub.get().hide(true); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQuery.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQuery.kt new file mode 100644 index 0000000000..b14631d181 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQuery.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +/** + * Represents an inline query via compose text. + */ +sealed class InlineQuery(val query: String) { + object NoQuery : InlineQuery("") + class Emoji(query: String, val keywordSearch: Boolean) : InlineQuery(query.replace('_', ' ')) + class Mention(query: String) : InlineQuery(query) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryAdapter.kt new file mode 100644 index 0000000000..055fb27edb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryAdapter.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter + +class InlineQueryAdapter(listener: (AnyMappingModel) -> Unit) : MappingAdapter() { + init { + registerFactory(InlineQueryEmojiResult.Model::class.java, { InlineQueryEmojiResult.ViewHolder(it, listener) }, R.layout.inline_query_emoji_result) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryChangedListener.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryChangedListener.kt new file mode 100644 index 0000000000..116f2a53ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryChangedListener.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +/** + * Called when a query changes. + */ +interface InlineQueryChangedListener { + fun onQueryChanged(inlineQuery: InlineQuery) + fun clearQuery() = onQueryChanged(InlineQuery.NoQuery) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryEmojiResult.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryEmojiResult.kt new file mode 100644 index 0000000000..4d2bc37b8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryEmojiResult.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +/** + * Used to render inline emoji search results in a [org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter] + */ +object InlineQueryEmojiResult { + + class Model(val canonicalEmoji: String, val preferredEmoji: String, val keywordSearch: Boolean) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean { + return canonicalEmoji == newItem.canonicalEmoji + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return preferredEmoji == newItem.preferredEmoji + } + } + + class ViewHolder(itemView: View, private val listener: (AnyMappingModel) -> Unit) : MappingViewHolder(itemView) { + + private val emoji: EmojiImageView = findViewById(R.id.inline_query_emoji_image) + + override fun bind(model: Model) { + itemView.setOnClickListener { listener(model) } + emoji.setImageEmoji(model.preferredEmoji) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt new file mode 100644 index 0000000000..3daefab310 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import android.content.Context + +/** + * Encapsulate how to replace a query with a user selected result. + */ +sealed class InlineQueryReplacement(@get:JvmName("isKeywordSearch") val keywordSearch: Boolean = false) { + abstract fun toCharSequence(context: Context): CharSequence + + class Emoji(private val emoji: String, keywordSearch: Boolean) : InlineQueryReplacement(keywordSearch) { + override fun toCharSequence(context: Context): CharSequence { + return "$emoji " + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsController.kt new file mode 100644 index 0000000000..afc3d27141 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsController.kt @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.components.ComposeText +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.VibrateUtil +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel +import org.thoughtcrime.securesms.util.doOnEachLayout + +/** + * Controller for inline search results. + */ +class InlineQueryResultsController( + private val context: Context, + private val viewModel: InlineQueryViewModel, + private val anchor: View, + private val container: ViewGroup, + editText: ComposeText, + lifecycleOwner: LifecycleOwner +) : InlineQueryResultsPopup.Callback { + + private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable() + private var popup: InlineQueryResultsPopup? = null + private var previousResults: List? = null + private var canShow: Boolean = false + + init { + lifecycleDisposable.bindTo(lifecycleOwner) + + lifecycleDisposable += viewModel.results + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { updateList(it) } + + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + dismiss() + } + }) + + editText.addOnFocusChangeListener { _, hasFocus -> + canShow = hasFocus + updateList(previousResults ?: emptyList()) + } + + anchor.doOnEachLayout { popup?.updateWithAnchor() } + } + + override fun onSelection(model: AnyMappingModel) { + viewModel.onSelection(model) + } + + override fun onDismiss() { + popup = null + } + + fun onOrientationChange(isLandscape: Boolean) { + if (isLandscape) { + dismiss() + } else { + updateList(previousResults ?: emptyList()) + } + } + + private fun updateList(results: List) { + previousResults = results + if (results.isEmpty() || !canShow) { + dismiss() + } else if (popup != null) { + popup?.setResults(results) + } else { + popup = InlineQueryResultsPopup( + anchor = anchor, + container = container, + results = results, + baseOffsetX = DimensionUnit.DP.toPixels(16f).toInt(), + callback = this + ).show() + VibrateUtil.vibrateTick(context) + } + } + + private fun dismiss() { + popup?.dismiss() + popup = null + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsPopup.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsPopup.kt new file mode 100644 index 0000000000..97578538ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsPopup.kt @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import android.content.Context +import android.graphics.Rect +import android.os.Build +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupWindow +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter + +class InlineQueryResultsPopup( + val anchor: View, + val container: ViewGroup, + results: List, + val baseOffsetX: Int = 0, + val baseOffsetY: Int = 0, + var callback: Callback? +) : PopupWindow( + LayoutInflater.from(anchor.context).inflate(R.layout.inline_query_results_popup, null), + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + false +) { + + private val context: Context = anchor.context + + private val list: RecyclerView = contentView.findViewById(R.id.inline_query_results_list) + private val adapter: MappingAdapter + + init { + setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background)) + inputMethodMode = INPUT_METHOD_NOT_NEEDED + + setOnDismissListener { + callback?.onDismiss() + callback = null + } + + if (Build.VERSION.SDK_INT >= 21) { + elevation = 20f + } + + adapter = InlineQueryAdapter { m -> callback?.onSelection(m) } + list.adapter = adapter + list.itemAnimator = null + + setResults(results) + } + + fun setResults(results: List) { + adapter.submitList(results) { list.scrollToPosition(0) } + } + + fun show(): InlineQueryResultsPopup { + if (anchor.width == 0 || anchor.height == 0) { + anchor.post(this::show) + return this + } + + val (offsetX, offsetY) = calculateOffsets() + + showAsDropDown(anchor, offsetX, offsetY) + + return this + } + + fun updateWithAnchor() { + val (offsetX, offsetY) = calculateOffsets() + update(anchor, offsetX, offsetY, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + private fun calculateOffsets(): Pair { + contentView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + + val anchorRect = Rect(anchor.left, anchor.top, anchor.right, anchor.bottom).also { + if (anchor.parent != container) { + container.offsetDescendantRectToMyCoords(anchor, it) + } + } + + val menuBottomBound = anchorRect.bottom + contentView.measuredHeight + baseOffsetY + val menuTopBound = anchorRect.top - contentView.measuredHeight - baseOffsetY + + val screenBottomBound = container.height + val screenTopBound = container.y + + val offsetY: Int = when { + menuTopBound > screenTopBound -> -(anchorRect.height() + contentView.measuredHeight + baseOffsetY) + menuBottomBound < screenBottomBound -> baseOffsetY + menuTopBound > screenTopBound -> -(anchorRect.height() + contentView.measuredHeight + baseOffsetY) + else -> -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY) + } + + val offsetX: Int = if (ViewUtil.isLtr(context)) { + baseOffsetX + } else { + -(baseOffsetX + contentView.measuredWidth) + } + + return offsetX to offsetY + } + + interface Callback { + fun onSelection(model: AnyMappingModel) + fun onDismiss() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModel.kt new file mode 100644 index 0000000000..5b1d3eecb0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModel.kt @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchRepository +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel + +/** + * Activity (at least) scope view model for managing inline queries. The view model needs to be larger scope so it can + * be shared between the fragment requesting the search and the instace of [InlineQueryResultsFragment] used for displaying + * the results. + */ +class InlineQueryViewModel(private val emojiSearchRepository: EmojiSearchRepository = EmojiSearchRepository(ApplicationDependencies.getApplication())) : ViewModel() { + + private val querySubject: PublishSubject = PublishSubject.create() + private val selectionSubject: PublishSubject = PublishSubject.create() + + val results: Observable> + val selection: Observable = selectionSubject + + init { + results = querySubject.switchMap { query -> + when (query) { + is InlineQuery.Emoji -> queryEmoji(query) + is InlineQuery.Mention -> Observable.just(emptyList()) + InlineQuery.NoQuery -> Observable.just(emptyList()) + } + }.subscribeOn(Schedulers.io()) + } + + fun onQueryChange(inlineQuery: InlineQuery) { + querySubject.onNext(inlineQuery) + } + + private fun queryEmoji(query: InlineQuery.Emoji): Observable> { + return emojiSearchRepository + .submitQuery(query.query) + .map { r -> toMappingModels(r, query.keywordSearch) } + .toObservable() + } + + fun onSelection(model: AnyMappingModel) { + when (model) { + is InlineQueryEmojiResult.Model -> { + selectionSubject.onNext(InlineQueryReplacement.Emoji(model.preferredEmoji, model.keywordSearch)) + } + } + } + + companion object { + fun toMappingModels(emojiWithLabels: List, keywordSearch: Boolean): List { + val emojiValues = SignalStore.emojiValues() + return emojiWithLabels + .distinct() + .map { emoji -> + InlineQueryEmojiResult.Model( + canonicalEmoji = emoji, + preferredEmoji = emojiValues.getPreferredVariation(emoji), + keywordSearch = keywordSearch + ) + } + } + } +} 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 index 9485b7920b..609e275e28 100644 --- 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 @@ -28,8 +28,6 @@ public class MentionsPickerFragment extends LoggingFragment { private MentionsPickerAdapter adapter; private RecyclerView list; - private View topDivider; - private View bottomDivider; private BottomSheetBehavior behavior; private MentionsPickerViewModel viewModel; private final Runnable lockSheetAfterListUpdate = () -> behavior.setHideable(false); @@ -40,8 +38,6 @@ public class MentionsPickerFragment extends LoggingFragment { View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false); list = view.findViewById(R.id.mentions_picker_list); - topDivider = view.findViewById(R.id.mentions_picker_top_divider); - bottomDivider = view.findViewById(R.id.mentions_picker_bottom_divider); behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet)); initializeBehavior(); @@ -74,15 +70,12 @@ public class MentionsPickerFragment extends LoggingFragment { public void onStateChanged(@NonNull View bottomSheet, int newState) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { adapter.submitList(Collections.emptyList()); - showDividers(false); } else { - showDividers(true); } } @Override public void onSlide(@NonNull View bottomSheet, float slideOffset) { - showDividers(Float.isNaN(slideOffset) || slideOffset > -0.8f); } }); } @@ -116,16 +109,10 @@ public class MentionsPickerFragment extends LoggingFragment { list.scrollToPosition(0); behavior.setState(BottomSheetBehavior.STATE_COLLAPSED); handler.post(lockSheetAfterListUpdate); - showDividers(true); } else { handler.removeCallbacks(lockSheetAfterListUpdate); behavior.setHideable(true); behavior.setState(BottomSheetBehavior.STATE_HIDDEN); } } - - private void showDividers(boolean showDividers) { - topDivider.setVisibility(showDividers ? View.VISIBLE : View.GONE); - bottomDivider.setVisibility(showDividers ? View.VISIBLE : View.GONE); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java index 1438a44449..e74870d9f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.java @@ -7,10 +7,10 @@ import android.text.TextUtils; import androidx.annotation.NonNull; -import org.thoughtcrime.securesms.database.model.EmojiSearchData; import org.signal.core.util.CursorUtil; -import org.thoughtcrime.securesms.util.FtsUtil; import org.signal.core.util.SqlUtil; +import org.thoughtcrime.securesms.database.model.EmojiSearchData; +import org.thoughtcrime.securesms.util.FtsUtil; import java.util.LinkedList; import java.util.List; @@ -48,7 +48,7 @@ public class EmojiSearchDatabase extends Database { String selection = LABEL + " MATCH (?)"; String[] args = SqlUtil.buildArgs(matchString); - try (Cursor cursor = db.query(true, TABLE_NAME, projection, selection, args, null, null,"rank", String.valueOf(limit))) { + try (Cursor cursor = db.query(true, TABLE_NAME, projection, selection, args, null, null, "rank", String.valueOf(limit))) { while (cursor.moveToNext()) { results.add(CursorUtil.requireString(cursor, EMOJI)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt index 068b13d6e9..a27cf83be6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/search/EmojiSearchRepository.kt @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.keyboard.emoji.search import android.content.Context import android.net.Uri +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.components.emoji.Emoji import org.thoughtcrime.securesms.components.emoji.EmojiPageModel @@ -13,12 +15,23 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences import java.util.function.Consumer private const val MINIMUM_QUERY_THRESHOLD = 1 +private const val MINIMUM_INLINE_QUERY_THRESHOLD = 2 private const val EMOJI_SEARCH_LIMIT = 20 class EmojiSearchRepository(private val context: Context) { private val emojiSearchDatabase: EmojiSearchDatabase = SignalDatabase.emojiSearch + fun submitQuery(query: String, limit: Int = EMOJI_SEARCH_LIMIT): Single> { + if (query.length < MINIMUM_INLINE_QUERY_THRESHOLD) { + return Single.just(emptyList()) + } + + return Single.fromCallable> { + emojiSearchDatabase.query(query, limit) + }.subscribeOn(Schedulers.io()) + } + fun submitQuery(query: String, includeRecents: Boolean, limit: Int = EMOJI_SEARCH_LIMIT, consumer: Consumer) { if (query.length < MINIMUM_QUERY_THRESHOLD && includeRecents) { consumer.accept(RecentEmojiPageModel(context, TextSecurePreferences.RECENT_STORAGE_KEY)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index d5a1b092a9..23a22c673b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -11,6 +11,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign import org.signal.core.util.EditTextUtil import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ComposeText @@ -19,6 +20,11 @@ import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment import org.thoughtcrime.securesms.components.emoji.EmojiToggle import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery.NoQuery +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel import org.thoughtcrime.securesms.keyboard.KeyboardPage @@ -48,11 +54,16 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a factoryProducer = { MentionsPickerViewModel.Factory() } ) + private val inlineQueryViewModel: InlineQueryViewModel by viewModels( + ownerProducer = { requireActivity() } + ) + private lateinit var input: ComposeText private lateinit var emojiDrawerToggle: EmojiToggle private lateinit var emojiDrawerStub: Stub private lateinit var hud: InputAwareLayout private lateinit var mentionsContainer: ViewGroup + private lateinit var inlineQueryResultsController: InlineQueryResultsController private var requestedEmojiDrawer: Boolean = false @@ -136,7 +147,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a super.onDestroyView() disposables.dispose() - input.setMentionQueryChangedListener(null) + input.setInlineQueryChangedListener(null) input.setMentionValidator(null) } @@ -145,15 +156,43 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a mentionsContainer = requireView().findViewById(R.id.mentions_picker_container) + inlineQueryResultsController = InlineQueryResultsController( + requireContext(), + inlineQueryViewModel, + requireView().findViewById(R.id.background_holder), + (requireView() as ViewGroup), + input, + viewLifecycleOwner + ) + Recipient.live(recipientId).observe(viewLifecycleOwner) { recipient -> mentionsViewModel.onRecipientChange(recipient) - input.setMentionQueryChangedListener { query -> - if (recipient.isPushV2Group) { - ensureMentionsContainerFilled() - mentionsViewModel.onQueryChange(query) + input.setInlineQueryChangedListener(object : InlineQueryChangedListener { + override fun onQueryChanged(inlineQuery: InlineQuery) { + when (inlineQuery) { + is InlineQuery.Mention -> { + if (recipient.isPushV2Group && recipient.isActiveGroup) { + ensureMentionsContainerFilled() + mentionsViewModel.onQueryChange(inlineQuery.query) + } + inlineQueryViewModel.onQueryChange(inlineQuery) + } + is InlineQuery.Emoji -> { + inlineQueryViewModel.onQueryChange(inlineQuery) + mentionsViewModel.onQueryChange(null) + } + is NoQuery -> { + mentionsViewModel.onQueryChange(null) + inlineQueryViewModel.onQueryChange(inlineQuery) + } + } } - } + + override fun clearQuery() { + onQueryChanged(NoQuery) + } + }) input.setMentionValidator { annotations -> if (!recipient.isPushV2Group) { @@ -174,6 +213,11 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a mentionsViewModel.selectedRecipient.observe(viewLifecycleOwner) { recipient -> input.replaceTextWithMention(recipient.getDisplayName(requireContext()), recipient.id) } + + disposables += inlineQueryViewModel + .selection + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { r -> input.replaceText(r) } } private fun ensureMentionsContainerFilled() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt index 2dc9ba62f2..8806782e3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.annotation.ColorInt import androidx.fragment.app.Fragment @@ -15,6 +16,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehaviorHack import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log @@ -27,6 +29,10 @@ import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MarkReadHelper import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel import org.thoughtcrime.securesms.database.model.Mention @@ -101,6 +107,10 @@ class StoryGroupReplyFragment : ownerProducer = { requireActivity() } ) + private val inlineQueryViewModel: InlineQueryViewModel by viewModels( + ownerProducer = { requireActivity() } + ) + private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels( ownerProducer = { requireActivity() } ) @@ -145,6 +155,8 @@ class StoryGroupReplyFragment : private var resendMentions: List = emptyList() private var resendReaction: String? = null + private lateinit var inlineQueryResultsController: InlineQueryResultsController + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { SignalExecutors.BOUNDED.execute { RetrieveProfileJob.enqueue(groupRecipientId) @@ -227,7 +239,7 @@ class StoryGroupReplyFragment : override fun onDestroyView() { super.onDestroyView() - composer.input.setMentionQueryChangedListener(null) + composer.input.setInlineQueryChangedListener(null) composer.input.setMentionValidator(null) } @@ -422,15 +434,43 @@ class StoryGroupReplyFragment : } private fun initializeMentions() { + inlineQueryResultsController = InlineQueryResultsController( + requireContext(), + inlineQueryViewModel, + composer, + (requireView() as ViewGroup), + composer.input, + viewLifecycleOwner + ) + Recipient.live(groupRecipientId).observe(viewLifecycleOwner) { recipient -> mentionsViewModel.onRecipientChange(recipient) - composer.input.setMentionQueryChangedListener { query -> - if (recipient.isPushV2Group) { - ensureMentionsContainerFilled() - mentionsViewModel.onQueryChange(query) + composer.input.setInlineQueryChangedListener(object : InlineQueryChangedListener { + override fun onQueryChanged(inlineQuery: InlineQuery) { + when (inlineQuery) { + is InlineQuery.Mention -> { + if (recipient.isPushV2Group) { + ensureMentionsContainerFilled() + mentionsViewModel.onQueryChange(inlineQuery.query) + } + inlineQueryViewModel.onQueryChange(inlineQuery) + } + is InlineQuery.Emoji -> { + inlineQueryViewModel.onQueryChange(inlineQuery) + mentionsViewModel.onQueryChange(null) + } + is InlineQuery.NoQuery -> { + mentionsViewModel.onQueryChange(null) + inlineQueryViewModel.onQueryChange(inlineQuery) + } + } } - } + + override fun clearQuery() { + onQueryChanged(InlineQuery.NoQuery) + } + }) composer.input.setMentionValidator { annotations -> if (!recipient.isPushV2Group) { @@ -452,6 +492,11 @@ class StoryGroupReplyFragment : composer.input.replaceTextWithMention(recipient.getDisplayName(requireContext()), recipient.id) } + lifecycleDisposable += inlineQueryViewModel + .selection + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { r -> composer.input.replaceText(r) } + mentionsViewModel.isShowing.observe(viewLifecycleOwner) { updateNestedScrolling() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt index f8200a344e..60a93741a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -22,3 +22,9 @@ fun ConstraintLayout.changeConstraints(change: ConstraintSet.() -> Unit) { set.change() set.applyTo(this) } + +inline fun View.doOnEachLayout(crossinline action: (view: View) -> Unit): View.OnLayoutChangeListener { + val listener = View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> action(view) } + addOnLayoutChangeListener(listener) + return listener +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/AnyMappingModel.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/AnyMappingModel.kt new file mode 100644 index 0000000000..0b53403fbf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/AnyMappingModel.kt @@ -0,0 +1,4 @@ +package org.thoughtcrime.securesms.util.adapter.mapping + +/** Syntactic sugar for wildcard generic */ +typealias AnyMappingModel = MappingModel<*> diff --git a/app/src/main/res/layout/inline_query_emoji_result.xml b/app/src/main/res/layout/inline_query_emoji_result.xml new file mode 100644 index 0000000000..d57eddabda --- /dev/null +++ b/app/src/main/res/layout/inline_query_emoji_result.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/layout/inline_query_results_popup.xml b/app/src/main/res/layout/inline_query_results_popup.xml new file mode 100644 index 0000000000..9946840f8a --- /dev/null +++ b/app/src/main/res/layout/inline_query_results_popup.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/mentions_picker_fragment.xml b/app/src/main/res/layout/mentions_picker_fragment.xml index 07373894a4..a7a2b1a19f 100644 --- a/app/src/main/res/layout/mentions_picker_fragment.xml +++ b/app/src/main/res/layout/mentions_picker_fragment.xml @@ -10,32 +10,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - app:behavior_peekHeight="236dp" + app:behavior_peekHeight="216dp" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - - + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + android:background="@color/signal_colorSurface1" /> - -