Add inline emoji search.

This commit is contained in:
Cody Henthorne
2022-08-01 10:55:40 -04:00
committed by Greyson Parrelli
parent ba7319e215
commit 19af68a27c
22 changed files with 708 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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