From 4ce05a064ca0c178eac67ae736a3c50bd89953df Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 16 Jun 2023 12:26:23 -0300 Subject: [PATCH] Add CFV2 Sticker Suggestions. --- .../ConversationParentFragment.java | 2 +- .../conversation/v2/ConversationFragment.kt | 58 +++++++++++++++- .../conversation/v2/ConversationTooltips.kt | 27 ++++++++ .../v2/StickerSuggestionsViewModel.kt | 26 +++++++ .../keyboard/KeyboardPagerViewModel.kt | 3 +- .../stickers/StickerSearchRepository.java | 68 +++++++++++++------ 6 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/StickerSuggestionsViewModel.kt 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 19a0e7ebe2..88892f9c88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -2175,7 +2175,7 @@ public class ConversationParentFragment extends Fragment } private void initializeStickerObserver() { - StickerSearchRepository repository = new StickerSearchRepository(requireContext()); + StickerSearchRepository repository = new StickerSearchRepository(); stickerViewModel = new ViewModelProvider(this, (ViewModelProvider.Factory) new ConversationStickerViewModel.Factory(requireActivity().getApplication(), repository)) .get(ConversationStickerViewModel.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 71baceb36d..3dd7797a9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -188,6 +188,7 @@ import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiatio import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult import org.thoughtcrime.securesms.invites.InviteActions +import org.thoughtcrime.securesms.keyboard.KeyboardPage import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2 @@ -229,6 +230,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity import org.thoughtcrime.securesms.revealable.ViewOnceUtil import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stickers.StickerLocator +import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity @@ -333,6 +335,10 @@ class ConversationFragment : ConversationSearchViewModel(getString(R.string.note_to_self)) } + private val stickerViewModel: StickerSuggestionsViewModel by viewModel { + StickerSuggestionsViewModel() + } + private val conversationTooltips = ConversationTooltips(this) private val colorizer = Colorizer() private val textDraftSaveDebouncer = Debouncer(500) @@ -430,6 +436,7 @@ class ConversationFragment : container.fragmentManager = childFragmentManager ToolbarDependentMarginListener(binding.toolbar) + initializeMediaKeyboardToggle() } override fun onViewStateRestored(savedInstanceState: Bundle?) { @@ -676,6 +683,7 @@ class ConversationFragment : initializeSearch() initializeLinkPreviews() + initializeStickerSuggestions() inputPanel.setListener(InputPanelListener()) } @@ -1056,6 +1064,36 @@ class ConversationFragment : .addTo(disposables) } + private fun initializeMediaKeyboardToggle() { + val isSystemEmojiPreferred = SignalStore.settings().isPreferSystemEmoji + val keyboardMode: TextSecurePreferences.MediaKeyboardMode = TextSecurePreferences.getMediaKeyboardMode(requireContext()) + val stickerIntro: Boolean = !TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext()) + + inputPanel.showMediaKeyboardToggle(true) + + val toggleMode = when (keyboardMode) { + TextSecurePreferences.MediaKeyboardMode.EMOJI -> if (isSystemEmojiPreferred) KeyboardPage.STICKER else KeyboardPage.EMOJI + TextSecurePreferences.MediaKeyboardMode.STICKER -> KeyboardPage.STICKER + TextSecurePreferences.MediaKeyboardMode.GIF -> KeyboardPage.GIF + } + + inputPanel.setMediaKeyboardToggleMode(toggleMode) + + if (stickerIntro) { + TextSecurePreferences.setMediaKeyboardMode(requireContext(), TextSecurePreferences.MediaKeyboardMode.STICKER) + inputPanel.setMediaKeyboardToggleMode(KeyboardPage.STICKER) + conversationTooltips.displayStickerIntroductionTooltip(inputPanel.mediaKeyboardToggleAnchorView) { + EventBus.getDefault().removeStickyEvent(StickerPackInstallEvent::class.java) + } + } + } + + private fun initializeStickerSuggestions() { + stickerViewModel.stickers + .subscribeBy(onNext = inputPanel::setStickerSuggestions) + .addTo(disposables) + } + private fun updateLinkPreviewState() { if (/* TODO [cfv2] -- viewModel.isPushAvailable && */ !attachmentManager.isAttachmentPresent && context != null) { linkPreviewViewModel.onEnabled() @@ -2739,7 +2777,8 @@ class ConversationFragment : if (composeText.textTrimmed.isEmpty() || beforeLength == 0) { composeText.postDelayed({ updateToggleButtonState() }, 50) } - // todo [cfv2] stickerViewModel.onInputTextUpdated(s.toString()) + + stickerViewModel.onInputTextUpdated(s.toString()) } override fun onFocusChange(v: View, hasFocus: Boolean) { @@ -2942,6 +2981,23 @@ class ConversationFragment : viewModel.updateIdentityRecords() } + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + fun onStickerPackInstalled(event: StickerPackInstallEvent?) { + if (event == null) { + return + } + + if (!TextSecurePreferences.hasSeenStickerIntroTooltip(requireContext())) { + return + } + + EventBus.getDefault().removeStickyEvent(event) + + if (!inputPanel.isStickerMode) { + conversationTooltips.displayStickerPackInstalledTooltip(inputPanel.mediaKeyboardToggleAnchorView, event) + } + } + //endregion private inner class SearchEventListener : ConversationSearchBottomBar.EventListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt index 24148445ea..a3bcee33f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt @@ -9,6 +9,8 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.TooltipPopup import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent +import org.thoughtcrime.securesms.util.TextSecurePreferences /** * Any and all tooltips that the conversation can display, and a light amount of related presentation logic. @@ -51,6 +53,31 @@ class ConversationTooltips(fragment: Fragment) { .show(TooltipPopup.POSITION_BELOW) } + /** + * Displayed to teach the user about sticker packs + */ + fun displayStickerIntroductionTooltip(anchor: View, onDismiss: () -> Unit) { + TooltipPopup.forTarget(anchor) + .setBackgroundTint(ContextCompat.getColor(anchor.context, R.color.core_ultramarine)) + .setTextColor(ContextCompat.getColor(anchor.context, R.color.core_white)) + .setText(R.string.ConversationActivity_new_say_it_with_stickers) + .setOnDismissListener { + TextSecurePreferences.setHasSeenStickerIntroTooltip(anchor.context, true) + onDismiss() + } + .show(TooltipPopup.POSITION_ABOVE) + } + + /** + * Displayed after a sticker pack is installed + */ + fun displayStickerPackInstalledTooltip(anchor: View, event: StickerPackInstallEvent) { + TooltipPopup.forTarget(anchor) + .setText(R.string.ConversationActivity_sticker_pack_installed) + .setIconGlideModel(event.iconGlideModel) + .show(TooltipPopup.POSITION_ABOVE) + } + /** * ViewModel which holds different bits of session-local persistent state for different tooltips. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/StickerSuggestionsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/StickerSuggestionsViewModel.kt new file mode 100644 index 0000000000..55470a9281 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/StickerSuggestionsViewModel.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.processors.BehaviorProcessor +import org.thoughtcrime.securesms.database.model.StickerRecord +import org.thoughtcrime.securesms.stickers.StickerSearchRepository + +class StickerSuggestionsViewModel( + private val stickerSearchRepository: StickerSearchRepository = StickerSearchRepository() +) : ViewModel() { + + private val stickerSearchProcessor = BehaviorProcessor.createDefault("") + + val stickers: Flowable> = stickerSearchProcessor + .switchMapSingle { stickerSearchRepository.searchByEmoji(it) } + + fun onInputTextUpdated(text: String) { + stickerSearchProcessor.onNext(text) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt index cb52c0ab0b..711d008efe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerViewModel.kt @@ -4,7 +4,6 @@ import androidx.annotation.MainThread import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import org.signal.core.util.ThreadUtil -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.stickers.StickerSearchRepository import org.thoughtcrime.securesms.util.DefaultValueLiveData @@ -22,7 +21,7 @@ class KeyboardPagerViewModel : ViewModel() { pages = DefaultValueLiveData(startingPages) page = DefaultValueLiveData(startingPages.first()) - StickerSearchRepository(ApplicationDependencies.getApplication()).getStickerFeatureAvailability { available -> + StickerSearchRepository().getStickerFeatureAvailability { available -> if (!available) { val updatedPages = pages.value.toMutableSet().apply { remove(KeyboardPage.STICKER) } pages.postValue(updatedPages) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java index 1af51cc49c..4dbef34281 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.stickers; -import android.content.Context; import android.database.Cursor; import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.components.emoji.EmojiUtil; @@ -12,52 +12,80 @@ import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.StickerTable; import org.thoughtcrime.securesms.database.StickerTable.StickerRecordReader; import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.emoji.EmojiSource; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + public final class StickerSearchRepository { private final StickerTable stickerDatabase; private final AttachmentTable attachmentDatabase; - public StickerSearchRepository(@NonNull Context context) { + public StickerSearchRepository() { this.stickerDatabase = SignalDatabase.stickers(); this.attachmentDatabase = SignalDatabase.attachments(); } + public @NonNull Single> searchByEmoji(@NonNull String emoji) { + if (emoji.isEmpty() || emoji.length() > EmojiSource.getLatest().getMaxEmojiLength()) { + return Single.just(Collections.emptyList()); + } + + return Single.fromCallable(() -> searchByEmojiSync(emoji)); + } + public void searchByEmoji(@NonNull String emoji, @NonNull Callback> callback) { SignalExecutors.BOUNDED.execute(() -> { - String searchEmoji = EmojiUtil.getCanonicalRepresentation(emoji); - List out = new ArrayList<>(); - Set possible = EmojiUtil.getAllRepresentations(searchEmoji); + callback.onResult(searchByEmojiSync(emoji)); + }); + } - for (String candidate : possible) { - try (StickerRecordReader reader = new StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate))) { - StickerRecord record = null; - while ((record = reader.getNext()) != null) { - out.add(record); - } + @WorkerThread + private List searchByEmojiSync(@NonNull String emoji) { + String searchEmoji = EmojiUtil.getCanonicalRepresentation(emoji); + List out = new ArrayList<>(); + Set possible = EmojiUtil.getAllRepresentations(searchEmoji); + + for (String candidate : possible) { + try (StickerRecordReader reader = new StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate))) { + StickerRecord record = null; + while ((record = reader.getNext()) != null) { + out.add(record); } } + } - callback.onResult(out); - }); + return out; + } + + public @NonNull Single getStickerFeatureAvailability() { + return Single.fromCallable(this::getStickerFeatureAvailabilitySync) + .observeOn(Schedulers.io()); } public void getStickerFeatureAvailability(@NonNull Callback callback) { SignalExecutors.BOUNDED.execute(() -> { - try (Cursor cursor = stickerDatabase.getAllStickerPacks("1")) { - if (cursor != null && cursor.moveToFirst()) { - callback.onResult(true); - } else { - callback.onResult(attachmentDatabase.hasStickerAttachments()); - } - } + callback.onResult(getStickerFeatureAvailabilitySync()); }); } + @WorkerThread + private Boolean getStickerFeatureAvailabilitySync() { + try (Cursor cursor = stickerDatabase.getAllStickerPacks("1")) { + if (cursor != null && cursor.moveToFirst()) { + return true; + } else { + return attachmentDatabase.hasStickerAttachments(); + } + } + } + public interface Callback { void onResult(T result); }