Add scroll buttons to CFV2.

This commit is contained in:
Alex Hart
2023-04-21 10:49:30 -03:00
parent bff8fc8230
commit 694d8f1984
7 changed files with 182 additions and 50 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Int> {
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<MessageCounts> {
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
)
}

View File

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

View File

@@ -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<ConversationScrollButtonState> = scrollButtonStateStore.stateFlowable
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
private val _recipient: BehaviorSubject<Recipient> = BehaviorSubject.create()
val recipient: Observable<Recipient> = _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<Int> {
return repository.getQuotedMessagePosition(threadId, quote)
}
fun getNextMentionPosition(): Single<Int> {
return repository.getNextMentionPosition(threadId)
}
fun setLastScrolled(lastScrolledTimestamp: Long) {
repository.setLastVisibleMessageTimestamp(
threadId,

View File

@@ -97,6 +97,32 @@
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
<org.thoughtcrime.securesms.components.ConversationScrollToView
android:id="@+id/scroll_to_mention"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginBottom="12dp"
android:visibility="invisible"
app:cstv_scroll_button_src="@drawable/ic_at_20"
app:layout_constraintBottom_toTopOf="@id/scroll_to_bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginBottom="20dp"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.ConversationScrollToView
android:id="@+id/scroll_to_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginBottom="16dp"
android:visibility="invisible"
app:cstv_scroll_button_src="@drawable/ic_chevron_down_20"
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/conversation_bottom_panel_barrier"
android:layout_width="wrap_content"