Update stories jump logic to match spec.

fork-5.53.8
Alex Hart 2022-09-26 14:20:50 -03:00 zatwierdzone przez Cody Henthorne
rodzic e8c10cd550
commit 931b9f8831
8 zmienionych plików z 96 dodań i 82 usunięć

Wyświetl plik

@ -22,7 +22,8 @@ data class StoryViewerArgs(
val groupReplyStartPosition: Int = -1, val groupReplyStartPosition: Int = -1,
val isFromInfoContextMenuAction: Boolean = false, val isFromInfoContextMenuAction: Boolean = false,
val isFromQuote: Boolean = false, val isFromQuote: Boolean = false,
val isFromMyStories: Boolean = false val isFromMyStories: Boolean = false,
val isJumpToUnviewed: Boolean = false
) : Parcelable { ) : Parcelable {
class Builder(private val recipientId: RecipientId, private val isInHiddenStoryMode: Boolean) { class Builder(private val recipientId: RecipientId, private val isInHiddenStoryMode: Boolean) {

Wyświetl plik

@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.SearchBinder import org.thoughtcrime.securesms.main.SearchBinder
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
@ -306,8 +307,9 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
storyThumbTextModel = text, storyThumbTextModel = text,
storyThumbUri = image, storyThumbUri = image,
storyThumbBlur = blur, storyThumbBlur = blur,
recipientIds = viewModel.getRecipientIds(model.data.isHidden, false), recipientIds = viewModel.getRecipientIds(model.data.isHidden, model.data.storyViewState == StoryViewState.UNVIEWED),
isFromInfoContextMenuAction = isFromInfoContextMenuAction isFromInfoContextMenuAction = isFromInfoContextMenuAction,
isJumpToUnviewed = model.data.storyViewState == StoryViewState.UNVIEWED
) )
), ),
options.toBundle() options.toBundle()

Wyświetl plik

@ -7,8 +7,10 @@ import androidx.fragment.app.viewModels
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageArgs
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment
import org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView import org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView
import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.LifecycleDisposable
@ -47,11 +49,18 @@ class StoryViewerFragment :
val adapter = StoryViewerPagerAdapter( val adapter = StoryViewerPagerAdapter(
this, this,
storyViewerArgs.storyId, StoryViewerPageArgs(
storyViewerArgs.isFromNotification, recipientId = Recipient.UNKNOWN.id,
storyViewerArgs.groupReplyStartPosition, initialStoryId = storyViewerArgs.storyId,
storyViewerArgs.isFromMyStories, isJumpForwardToUnviewed = storyViewerArgs.isJumpToUnviewed,
storyViewerArgs.isFromInfoContextMenuAction isOutgoingOnly = storyViewerArgs.isFromMyStories,
source = when {
storyViewerArgs.isFromInfoContextMenuAction -> StoryViewerPageArgs.Source.INFO_CONTEXT
storyViewerArgs.isFromNotification -> StoryViewerPageArgs.Source.NOTIFICATION
else -> StoryViewerPageArgs.Source.UNKNOWN
},
groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition
)
) )
storyPager.adapter = adapter storyPager.adapter = adapter

Wyświetl plik

@ -5,15 +5,12 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageArgs
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment
class StoryViewerPagerAdapter( class StoryViewerPagerAdapter(
fragment: Fragment, fragment: Fragment,
private val initialStoryId: Long, private val arguments: StoryViewerPageArgs
private val isFromNotification: Boolean,
private val groupReplyStartPosition: Int,
private val isOutgoingOnly: Boolean,
private val isFromInfoContextMenuAction: Boolean
) : FragmentStateAdapter(fragment) { ) : FragmentStateAdapter(fragment) {
private val pages: MutableList<RecipientId> = mutableListOf() private val pages: MutableList<RecipientId> = mutableListOf()
@ -39,7 +36,7 @@ class StoryViewerPagerAdapter(
} }
override fun createFragment(position: Int): Fragment { override fun createFragment(position: Int): Fragment {
return StoryViewerPageFragment.create(pages[position], initialStoryId, isFromNotification, groupReplyStartPosition, isOutgoingOnly, isFromInfoContextMenuAction) return StoryViewerPageFragment.create(arguments.copy(recipientId = pages[position]))
} }
private class Callback( private class Callback(

Wyświetl plik

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.stories.viewer.page
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.recipients.RecipientId
@Parcelize
data class StoryViewerPageArgs(
val recipientId: RecipientId,
val initialStoryId: Long,
val isOutgoingOnly: Boolean,
val isJumpForwardToUnviewed: Boolean,
val source: Source,
val groupReplyStartPosition: Int
) : Parcelable {
enum class Source {
UNKNOWN,
NOTIFICATION,
INFO_CONTEXT
}
}

Wyświetl plik

@ -124,9 +124,7 @@ class StoryViewerPageFragment :
private val viewModel: StoryViewerPageViewModel by viewModels( private val viewModel: StoryViewerPageViewModel by viewModels(
factoryProducer = { factoryProducer = {
StoryViewerPageViewModel.Factory( StoryViewerPageViewModel.Factory(
storyRecipientId, storyViewerPageArgs,
initialStoryId,
isOutgoingOnly,
StoryViewerPageRepository( StoryViewerPageRepository(
requireContext() requireContext()
), ),
@ -149,23 +147,7 @@ class StoryViewerPageFragment :
private var sendingProgressDrawable: IndeterminateDrawable<CircularProgressIndicatorSpec>? = null private var sendingProgressDrawable: IndeterminateDrawable<CircularProgressIndicatorSpec>? = null
private val storyRecipientId: RecipientId private val storyViewerPageArgs: StoryViewerPageArgs by lazy(LazyThreadSafetyMode.NONE) { requireArguments().getParcelable(ARGS)!! }
get() = requireArguments().getParcelable(ARG_STORY_RECIPIENT_ID)!!
private val initialStoryId: Long
get() = requireArguments().getLong(ARG_STORY_ID, -1L)
private val isFromNotification: Boolean
get() = requireArguments().getBoolean(ARG_IS_FROM_NOTIFICATION, false)
private val groupReplyStartPosition: Int
get() = requireArguments().getInt(ARG_GROUP_REPLY_START_POSITION, -1)
private val isOutgoingOnly: Boolean
get() = requireArguments().getBoolean(ARG_IS_OUTGOING_ONLY, false)
private val isFromInfoContextMenuAction: Boolean
get() = requireArguments().getBoolean(ARG_IS_FROM_INFO_CONTEXT_MENU_ACTION, false)
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -357,7 +339,7 @@ class StoryViewerPageFragment :
if (parentState.pages.size <= parentState.page) { if (parentState.pages.size <= parentState.page) {
viewModel.setIsSelectedPage(false) viewModel.setIsSelectedPage(false)
} else if (storyRecipientId == parentState.pages[parentState.page]) { } else if (storyViewerPageArgs.recipientId == parentState.pages[parentState.page]) {
if (progressBar.segmentCount != 0) { if (progressBar.segmentCount != 0) {
progressBar.reset() progressBar.reset()
progressBar.setPosition(viewModel.getRestartIndex()) progressBar.setPosition(viewModel.getRestartIndex())
@ -412,16 +394,16 @@ class StoryViewerPageFragment :
viewModel.setAreSegmentsInitialized(true) viewModel.setAreSegmentsInitialized(true)
} else if (state.selectedPostIndex >= state.posts.size) { } else if (state.selectedPostIndex >= state.posts.size) {
callback.onFinishedPosts(storyRecipientId) callback.onFinishedPosts(storyViewerPageArgs.recipientId)
} else if (state.selectedPostIndex < 0) { } else if (state.selectedPostIndex < 0) {
callback.onGoToPreviousStory(storyRecipientId) callback.onGoToPreviousStory(storyViewerPageArgs.recipientId)
} }
if (state.isDisplayingInitialState && !sharedViewModel.hasConsumedInitialState) { if (state.isDisplayingInitialState && !sharedViewModel.hasConsumedInitialState) {
sharedViewModel.consumeInitialState() sharedViewModel.consumeInitialState()
if (isFromNotification) { if (storyViewerPageArgs.source == StoryViewerPageArgs.Source.NOTIFICATION) {
startReply(isFromNotification = true, groupReplyStartPosition = groupReplyStartPosition) startReply(isFromNotification = true, groupReplyStartPosition = storyViewerPageArgs.groupReplyStartPosition)
} else if (isFromInfoContextMenuAction && state.selectedPostIndex in state.posts.indices) { } else if (storyViewerPageArgs.source == StoryViewerPageArgs.Source.INFO_CONTEXT && state.selectedPostIndex in state.posts.indices) {
showInfo(state.posts[state.selectedPostIndex]) showInfo(state.posts[state.selectedPostIndex])
} }
} }
@ -1060,13 +1042,13 @@ class StoryViewerPageFragment :
} }
}, },
onGoToChat = { onGoToChat = {
startActivity(ConversationIntents.createBuilder(requireContext(), storyRecipientId, -1L).build()) startActivity(ConversationIntents.createBuilder(requireContext(), storyViewerPageArgs.recipientId, -1L).build())
}, },
onHide = { onHide = {
viewModel.setIsDisplayingHideDialog(true) viewModel.setIsDisplayingHideDialog(true)
StoryDialogs.hideStory(requireContext(), Recipient.resolved(storyRecipientId).getDisplayName(requireContext()), { viewModel.setIsDisplayingHideDialog(true) }) { StoryDialogs.hideStory(requireContext(), Recipient.resolved(storyViewerPageArgs.recipientId).getDisplayName(requireContext()), { viewModel.setIsDisplayingHideDialog(true) }) {
lifecycleDisposable += viewModel.hideStory().subscribe { lifecycleDisposable += viewModel.hideStory().subscribe {
callback.onStoryHidden(storyRecipientId) callback.onStoryHidden(storyViewerPageArgs.recipientId)
} }
} }
}, },
@ -1099,29 +1081,12 @@ class StoryViewerPageFragment :
private val CHARACTERS_PER_SECOND = 15L private val CHARACTERS_PER_SECOND = 15L
private val DEFAULT_DURATION = TimeUnit.SECONDS.toMillis(5) private val DEFAULT_DURATION = TimeUnit.SECONDS.toMillis(5)
private const val ARG_STORY_RECIPIENT_ID = "arg.story.recipient.id" private const val ARGS = "args"
private const val ARG_STORY_ID = "arg.story.id"
private const val ARG_IS_FROM_NOTIFICATION = "is_from_notification"
private const val ARG_GROUP_REPLY_START_POSITION = "group_reply_start_position"
private const val ARG_IS_OUTGOING_ONLY = "is_outgoing_only"
private const val ARG_IS_FROM_INFO_CONTEXT_MENU_ACTION = "is_from_info_context_menu_action"
fun create( fun create(args: StoryViewerPageArgs): Fragment {
recipientId: RecipientId,
initialStoryId: Long,
isFromNotification: Boolean,
groupReplyStartPosition: Int,
isOutgoingOnly: Boolean,
isFromInfoContextMenuAction: Boolean
): Fragment {
return StoryViewerPageFragment().apply { return StoryViewerPageFragment().apply {
arguments = bundleOf( arguments = bundleOf(
ARG_STORY_RECIPIENT_ID to recipientId, ARGS to args
ARG_STORY_ID to initialStoryId,
ARG_IS_FROM_NOTIFICATION to isFromNotification,
ARG_GROUP_REPLY_START_POSITION to groupReplyStartPosition,
ARG_IS_OUTGOING_ONLY to isOutgoingOnly,
ARG_IS_FROM_INFO_CONTEXT_MENU_ACTION to isFromInfoContextMenuAction,
) )
} }
} }

Wyświetl plik

@ -25,9 +25,7 @@ import kotlin.math.min
* Encapsulates presentation logic for displaying a collection of posts from a given user's story * Encapsulates presentation logic for displaying a collection of posts from a given user's story
*/ */
class StoryViewerPageViewModel( class StoryViewerPageViewModel(
private val recipientId: RecipientId, private val args: StoryViewerPageArgs,
private val initialStoryId: Long,
private val isOutgoingOnly: Boolean,
private val repository: StoryViewerPageRepository, private val repository: StoryViewerPageRepository,
val storyCache: StoryCache val storyCache: StoryCache
) : ViewModel() { ) : ViewModel() {
@ -63,11 +61,11 @@ class StoryViewerPageViewModel(
fun refresh() { fun refresh() {
disposables.clear() disposables.clear()
disposables += repository.getStoryPostsFor(recipientId, isOutgoingOnly).subscribe { posts -> disposables += repository.getStoryPostsFor(args.recipientId, args.isOutgoingOnly).subscribe { posts ->
store.update { state -> store.update { state ->
val isDisplayingInitialState = state.posts.isEmpty() && posts.isNotEmpty() val isDisplayingInitialState = state.posts.isEmpty() && posts.isNotEmpty()
val startIndex = if (state.posts.isEmpty() && initialStoryId > 0) { val startIndex = if (state.posts.isEmpty() && args.initialStoryId > 0) {
val initialIndex = posts.indexOfFirst { it.id == initialStoryId } val initialIndex = posts.indexOfFirst { it.id == args.initialStoryId }
initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex
} else if (state.posts.isEmpty()) { } else if (state.posts.isEmpty()) {
val initialPost = getNextUnreadPost(posts) val initialPost = getNextUnreadPost(posts)
@ -104,7 +102,7 @@ class StoryViewerPageViewModel(
} }
fun hideStory(): Completable { fun hideStory(): Completable {
return repository.hideStory(recipientId) return repository.hideStory(args.recipientId)
} }
fun markViewed(storyPost: StoryPost) { fun markViewed(storyPost: StoryPost) {
@ -133,10 +131,10 @@ class StoryViewerPageViewModel(
val postIndex = store.state.selectedPostIndex val postIndex = store.state.selectedPostIndex
val nextUnreadPost: StoryPost? = getNextUnreadPost(store.state.posts.drop(postIndex + 1)) val nextUnreadPost: StoryPost? = getNextUnreadPost(store.state.posts.drop(postIndex + 1))
if (nextUnreadPost == null) { when {
setSelectedPostIndex(postIndex + 1) nextUnreadPost == null && args.isJumpForwardToUnviewed -> setSelectedPostIndex(store.state.posts.size)
} else { nextUnreadPost == null -> setSelectedPostIndex(postIndex + 1)
setSelectedPostIndex(store.state.posts.indexOf(nextUnreadPost)) else -> setSelectedPostIndex(store.state.posts.indexOf(nextUnreadPost))
} }
} }
@ -311,14 +309,12 @@ class StoryViewerPageViewModel(
} }
class Factory( class Factory(
private val recipientId: RecipientId, private val args: StoryViewerPageArgs,
private val initialStoryId: Long,
private val isOutgoingOnly: Boolean,
private val repository: StoryViewerPageRepository, private val repository: StoryViewerPageRepository,
private val storyCache: StoryCache private val storyCache: StoryCache
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, isOutgoingOnly, repository, storyCache)) as T return modelClass.cast(StoryViewerPageViewModel(args, repository, storyCache)) as T
} }
} }
} }

Wyświetl plik

@ -145,6 +145,24 @@ class StoryViewerPageViewModelTest {
testSubscriber.assertValueAt(0) { it.selectedPostIndex == 2 } testSubscriber.assertValueAt(0) { it.selectedPostIndex == 2 }
} }
@Test
fun `Given no unread and jump to next unread enabled, when I goToNext, then I expect storyIndex to be size`() {
// GIVEN
val storyPosts = createStoryPosts(3) { true }
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
// WHEN
val testSubject = createTestSubject(isJumpForwardToUnviewed = true)
testScheduler.triggerActions()
testSubject.goToNextPost()
testScheduler.triggerActions()
// THEN
val testSubscriber = testSubject.state.test()
testSubscriber.assertValueAt(0) { it.selectedPostIndex == 3 }
}
@Test @Test
fun `Given a single story, when I goToPrevious, then I expect storyIndex to be -1`() { fun `Given a single story, when I goToPrevious, then I expect storyIndex to be -1`() {
// GIVEN // GIVEN
@ -163,11 +181,16 @@ class StoryViewerPageViewModelTest {
testSubscriber.assertValueAt(0) { it.selectedPostIndex == -1 } testSubscriber.assertValueAt(0) { it.selectedPostIndex == -1 }
} }
private fun createTestSubject(): StoryViewerPageViewModel { private fun createTestSubject(isJumpForwardToUnviewed: Boolean = false): StoryViewerPageViewModel {
return StoryViewerPageViewModel( return StoryViewerPageViewModel(
RecipientId.from(1), StoryViewerPageArgs(
-1L, recipientId = RecipientId.from(1),
false, initialStoryId = -1L,
isOutgoingOnly = false,
isJumpForwardToUnviewed = isJumpForwardToUnviewed,
source = StoryViewerPageArgs.Source.UNKNOWN,
groupReplyStartPosition = -1
),
repository, repository,
mock() mock()
) )