diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java index 8dd0ae69ca..f6487356a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationScrollToView.java @@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; @@ -14,11 +17,14 @@ import androidx.annotation.Nullable; import com.airbnb.lottie.SimpleColorFilter; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; public final class ConversationScrollToView extends FrameLayout { private final TextView unreadCount; private final ImageView scrollButton; + private final Animation inAnimation; + private final Animation outAnimation; public ConversationScrollToView(@NonNull Context context) { this(context, null); @@ -44,6 +50,20 @@ public final class ConversationScrollToView extends FrameLayout { array.recycle(); } + + inAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in); + outAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out); + + inAnimation.setDuration(100); + outAnimation.setDuration(50); + } + + public void setShown(boolean isShown) { + if (isShown) { + ViewUtil.animateIn(this, inAnimation); + } else { + ViewUtil.animateOut(this, outAnimation, View.INVISIBLE); + } } public void setWallpaperEnabled(boolean hasWallpaper) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index a752150e2a..f9bfcf5d39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -17,12 +17,8 @@ package org.thoughtcrime.securesms.conversation; import android.Manifest; -import android.animation.Animator; -import android.animation.LayoutTransition; -import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.app.ActivityOptions; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; @@ -46,7 +42,6 @@ import android.widget.TextView; import android.widget.Toast; import android.widget.ViewSwitcher; -import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -77,6 +72,7 @@ import org.jetbrains.annotations.NotNull; import org.signal.core.util.DimensionUnit; import org.signal.core.util.Stopwatch; import org.signal.core.util.StreamUtil; +import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; @@ -138,7 +134,6 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult; import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; -import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; @@ -181,9 +176,8 @@ import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.HtmlUtil; -import org.signal.core.util.concurrent.LifecycleDisposable; -import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.MessageConstraintsUtil; +import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SignalLocalMetrics; @@ -253,10 +247,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect private ConversationGroupViewModel groupViewModel; private SnapToTopDataObserver snapToTopDataObserver; private MarkReadHelper markReadHelper; - private Animation scrollButtonInAnimation; - private Animation mentionButtonInAnimation; - private Animation scrollButtonOutAnimation; - private Animation mentionButtonOutAnimation; private OnScrollListener conversationScrollListener; private int lastSeenScrollOffset; private Stopwatch startupStopwatch; @@ -404,19 +394,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect })); conversationViewModel.getShowMentionsButton().observe(getViewLifecycleOwner(), shouldShow -> { - if (shouldShow) { - ViewUtil.animateIn(scrollToMentionButton, mentionButtonInAnimation); - } else { - ViewUtil.animateOut(scrollToMentionButton, mentionButtonOutAnimation, View.INVISIBLE); - } + scrollToMentionButton.setShown(shouldShow); }); conversationViewModel.getShowScrollToBottom().observe(getViewLifecycleOwner(), shouldShow -> { - if (shouldShow) { - ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation); - } else { - ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE); - } + scrollToBottomButton.setShown(shouldShow); }); scrollToBottomButton.setOnClickListener(v -> scrollToBottom()); @@ -445,7 +427,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect conversationViewModel.getActiveNotificationProfile().observe(getViewLifecycleOwner(), this::updateNotificationProfileStatus); - initializeScrollButtonAnimations(); initializeResources(); initializeMessageRequestViewModel(); initializeListAdapter(); @@ -1370,20 +1351,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } } - private void initializeScrollButtonAnimations() { - scrollButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in); - scrollButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out); - - mentionButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in); - mentionButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out); - - scrollButtonInAnimation.setDuration(100); - scrollButtonOutAnimation.setDuration(50); - - mentionButtonInAnimation.setDuration(100); - mentionButtonOutAnimation.setDuration(50); - } - private void scrollToNextMention() { SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { return SignalDatabase.messages().getOldestUnreadMentionDetails(threadId); 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 14fd1254cd..b9a65d290d 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 @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu import org.thoughtcrime.securesms.conversation.MarkReadHelper +import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.conversation.colors.Colorizer import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator @@ -139,6 +140,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) ) private val conversationTooltips = ConversationTooltips(this) + private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider private lateinit var layoutManager: SmoothScrollingLinearLayoutManager private lateinit var markReadHelper: MarkReadHelper @@ -146,6 +148,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private lateinit var addToContactsLauncher: ActivityResultLauncher private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate private lateinit var adapter: ConversationAdapter + private lateinit var recyclerViewColorizer: RecyclerViewColorizer private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy { override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) { @@ -165,11 +168,20 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true) binding.conversationItemRecycler.setHasFixedSize(false) binding.conversationItemRecycler.layoutManager = layoutManager + binding.conversationItemRecycler.addOnScrollListener(ScrollListener()) + + binding.scrollToBottom.setOnClickListener { + scrollToPositionDelegate.resetScrollPosition() + } + + binding.scrollToMention.setOnClickListener { + scrollToNextMention() + } val layoutTransitionListener = BubbleLayoutTransitionListener(binding.conversationItemRecycler) viewLifecycleOwner.lifecycle.addObserver(layoutTransitionListener) - val recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler) + recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler) recyclerViewColorizer.setChatColors(args.chatColors) val conversationToolbarOnScrollHelper = ConversationToolbarOnScrollHelper( @@ -190,16 +202,15 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) presentWallpaper(args.wallpaper) disposables += viewModel.recipient .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy(onNext = { - recyclerViewColorizer.setChatColors(it.chatColors) - presentWallpaper(it.wallpaper) - presentConversationTitle(it) - }) + .subscribeBy(onNext = this::onRecipientChanged) disposables += viewModel.markReadRequests .observeOn(AndroidSchedulers.mainThread()) .subscribeBy(onNext = markReadHelper::onViewsRevealed) + disposables += viewModel.scrollButtonState + .subscribeBy(onNext = this::presentScrollButtons) + EventBus.getDefault().registerForLifecycle(groupCallViewModel, viewLifecycleOwner) presentGroupCallJoinButton() } @@ -294,16 +305,15 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) adapter.notifyItemRangeChanged(0, adapter.itemCount) }) - binding.conversationItemRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val timestamp = MarkReadHelper.getLatestTimestamp(adapter, layoutManager) - timestamp.ifPresent(viewModel::requestMarkRead) - } - }) - presentActionBarMenu() } + private fun onRecipientChanged(recipient: Recipient) { + presentWallpaper(recipient.wallpaper) + presentConversationTitle(recipient) + presentChatColors(recipient.chatColors) + } + private fun invalidateOptionsMenu() { // TODO [alex] -- Handle search... is there a better way to manage this state? Maybe an event system? conversationOptionsMenuProvider.onCreateMenu(binding.toolbar.menu, requireActivity().menuInflater) @@ -352,6 +362,21 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } binding.conversationWallpaper.visible = chatWallpaper != null + binding.scrollToBottom.setWallpaperEnabled(chatWallpaper != null) + binding.scrollToMention.setWallpaperEnabled(chatWallpaper != null) + } + + private fun presentChatColors(chatColors: ChatColors) { + recyclerViewColorizer.setChatColors(chatColors) + binding.scrollToMention.setUnreadCountBackgroundTint(chatColors.asSingleColor()) + binding.scrollToBottom.setUnreadCountBackgroundTint(chatColors.asSingleColor()) + } + + private fun presentScrollButtons(scrollButtonState: ConversationScrollButtonState) { + Log.d(TAG, "Update scroll state $scrollButtonState") + binding.scrollToBottom.setUnreadCount(scrollButtonState.unreadCount) + binding.scrollToMention.isShown = scrollButtonState.hasMentions && scrollButtonState.showScrollButtons + binding.scrollToBottom.isShown = scrollButtonState.showScrollButtons } private fun presentGroupCallJoinButton() { @@ -444,6 +469,33 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) ) } + private fun scrollToNextMention() { + disposables += viewModel.getNextMentionPosition().subscribeBy { + moveToPosition(it) + } + } + + private fun isScrolledToBottom(): Boolean { + return !binding.conversationItemRecycler.canScrollVertically(1) + } + + private fun isScrolledPastButtonThreshold(): Boolean { + return layoutManager.findFirstCompletelyVisibleItemPosition() > 4 + } + + private inner class ScrollListener : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (isScrolledToBottom()) { + viewModel.setShowScrollButtons(false) + } else if (isScrolledPastButtonThreshold()) { + viewModel.setShowScrollButtons(true) + } + + val timestamp = MarkReadHelper.getLatestTimestamp(adapter, layoutManager) + timestamp.ifPresent(viewModel::requestMarkRead) + } + } + private inner class DataObserver( private val scrollToPositionDelegate: ScrollToPositionDelegate ) : RecyclerView.AdapterDataObserver() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 33c72b1633..246a6f118a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context +import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.subscribeBy @@ -11,6 +12,7 @@ import org.signal.paging.PagingConfig import org.thoughtcrime.securesms.conversation.ConversationDataSource import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.database.RxDatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads import org.thoughtcrime.securesms.database.model.Quote @@ -104,4 +106,36 @@ class ConversationRepository(context: Context) { SignalDatabase.messages.getQuotedMessagePosition(threadId, quote.id, quote.author) }.subscribeOn(Schedulers.io()) } + + fun getNextMentionPosition(threadId: Long): Single { + return Single.fromCallable { + val details = SignalDatabase.messages.getOldestUnreadMentionDetails(threadId) + if (details == null) { + -1 + } else { + SignalDatabase.messages.getMessagePositionInConversation(threadId, details.second(), details.first()) + } + }.subscribeOn(Schedulers.io()) + } + + fun getMessageCounts(threadId: Long): Flowable { + return RxDatabaseObserver.conversationList + .map { getUnreadCount(threadId) } + .distinctUntilChanged() + .map { MessageCounts(it, getUnreadMentionsCount(threadId)) } + } + + private fun getUnreadCount(threadId: Long): Int { + val threadRecord = threads.getThreadRecord(threadId) + return threadRecord?.unreadCount ?: 0 + } + + private fun getUnreadMentionsCount(threadId: Long): Int { + return SignalDatabase.messages.getUnreadMentionCount(threadId) + } + + data class MessageCounts( + val unread: Int, + val mentions: Int + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationScrollButtonState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationScrollButtonState.kt new file mode 100644 index 0000000000..d09f57b63a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationScrollButtonState.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.conversation.v2 + +data class ConversationScrollButtonState( + val showScrollButtons: Boolean = false, + val unreadCount: Int = 0, + val hasMentions: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 7c5d0c5931..0bf228b270 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2 import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single @@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.hasGiftBadge +import org.thoughtcrime.securesms.util.rx.RxStore import org.thoughtcrime.securesms.wallpaper.ChatWallpaper /** @@ -37,6 +39,11 @@ class ConversationViewModel( private val disposables = CompositeDisposable() private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper() + private val scrollButtonStateStore = RxStore(ConversationScrollButtonState()).addTo(disposables) + val scrollButtonState: Flowable = scrollButtonStateStore.stateFlowable + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + private val _recipient: BehaviorSubject = BehaviorSubject.create() val recipient: Observable = _recipient @@ -92,16 +99,35 @@ class ConversationViewModel( } } }.subscribe() + + disposables += scrollButtonStateStore.update( + repository.getMessageCounts(threadId) + ) { counts, state -> + state.copy( + unreadCount = counts.unread, + hasMentions = counts.mentions != 0 + ) + } } override fun onCleared() { disposables.clear() } + fun setShowScrollButtons(showScrollButtons: Boolean) { + scrollButtonStateStore.update { + it.copy(showScrollButtons = showScrollButtons) + } + } + fun getQuotedMessagePosition(quote: Quote): Single { return repository.getQuotedMessagePosition(threadId, quote) } + fun getNextMentionPosition(): Single { + return repository.getNextMentionPosition(threadId) + } + fun setLastScrolled(lastScrolledTimestamp: Long) { repository.setLastVisibleMessageTimestamp( threadId, diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index d553f78693..9fe5dd78c8 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -97,6 +97,32 @@ + + + + +