Add inline emoji search.

fork-5.53.8
Cody Henthorne 2022-08-01 10:55:40 -04:00 zatwierdzone przez Greyson Parrelli
rodzic ba7319e215
commit 19af68a27c
22 zmienionych plików z 708 dodań i 93 usunięć

Wyświetl plik

@ -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);
}
}

Wyświetl plik

@ -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<OnFocusChangeListener> 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;

Wyświetl plik

@ -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

Wyświetl plik

@ -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);
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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)
}
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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<Model> {
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<Model>(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)
}
}
}

Wyświetl plik

@ -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 "
}
}
}

Wyświetl plik

@ -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<AnyMappingModel>? = 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<AnyMappingModel>) {
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
}
}

Wyświetl plik

@ -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<AnyMappingModel>,
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<AnyMappingModel>) {
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<Int, Int> {
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()
}
}

Wyświetl plik

@ -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<InlineQuery> = PublishSubject.create()
private val selectionSubject: PublishSubject<InlineQueryReplacement> = PublishSubject.create()
val results: Observable<List<AnyMappingModel>>
val selection: Observable<InlineQueryReplacement> = 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<List<AnyMappingModel>> {
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<String>, keywordSearch: Boolean): List<AnyMappingModel> {
val emojiValues = SignalStore.emojiValues()
return emojiWithLabels
.distinct()
.map { emoji ->
InlineQueryEmojiResult.Model(
canonicalEmoji = emoji,
preferredEmoji = emojiValues.getPreferredVariation(emoji),
keywordSearch = keywordSearch
)
}
}
}
}

Wyświetl plik

@ -28,8 +28,6 @@ public class MentionsPickerFragment extends LoggingFragment {
private MentionsPickerAdapter adapter;
private RecyclerView list;
private View topDivider;
private View bottomDivider;
private BottomSheetBehavior<View> 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);
}
}

Wyświetl plik

@ -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));
}

Wyświetl plik

@ -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<List<String>> {
if (query.length < MINIMUM_INLINE_QUERY_THRESHOLD) {
return Single.just(emptyList())
}
return Single.fromCallable<List<String>> {
emojiSearchDatabase.query(query, limit)
}.subscribeOn(Schedulers.io())
}
fun submitQuery(query: String, includeRecents: Boolean, limit: Int = EMOJI_SEARCH_LIMIT, consumer: Consumer<EmojiPageModel>) {
if (query.length < MINIMUM_QUERY_THRESHOLD && includeRecents) {
consumer.accept(RecentEmojiPageModel(context, TextSecurePreferences.RECENT_STORAGE_KEY))

Wyświetl plik

@ -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<MediaKeyboard>
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() {

Wyświetl plik

@ -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<Mention> = 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() }
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -0,0 +1,4 @@
package org.thoughtcrime.securesms.util.adapter.mapping
/** Syntactic sugar for wildcard generic */
typealias AnyMappingModel = MappingModel<*>

Wyświetl plik

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:paddingHorizontal="4dp"
android:paddingVertical="8dp">
<org.thoughtcrime.securesms.components.emoji.EmojiImageView
android:id="@+id/inline_query_emoji_image"
android:layout_width="32dp"
android:layout_height="32dp"
tools:src="@drawable/ic_emoji_smiley_24" />
</FrameLayout>

Wyświetl plik

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/inline_query_results_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</FrameLayout>

Wyświetl plik

@ -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">
<View
android:id="@+id/mentions_picker_top_divider"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_gravity="top"
android:layout_marginTop="-2dp"
android:background="@drawable/compose_divider_background"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mentions_picker_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_background_dialog" />
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:background="@color/signal_colorSurface1" />
</FrameLayout>
<View
android:id="@+id/mentions_picker_bottom_divider"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_gravity="bottom"
android:background="@drawable/compose_divider_background"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>