Implement "unviewed only" mode for story viewer.

This commit is contained in:
Alex Hart 2022-06-24 14:14:13 -03:00 committed by Cody Henthorne
parent 89a6730efe
commit 858c7a7f2e
13 changed files with 91 additions and 34 deletions

View file

@ -19,7 +19,8 @@ data class StoryViewerArgs(
val storyThumbBlur: BlurHash? = null,
val recipientIds: List<RecipientId> = emptyList(),
val isFromNotification: Boolean = false,
val groupReplyStartPosition: Int = -1
val groupReplyStartPosition: Int = -1,
val isUnviewedOnly: Boolean = false
) : Parcelable {
class Builder(private val recipientId: RecipientId, private val isInHiddenStoryMode: Boolean) {
@ -31,6 +32,7 @@ data class StoryViewerArgs(
private var recipientIds: List<RecipientId> = emptyList()
private var isFromNotification: Boolean = false
private var groupReplyStartPosition: Int = -1
private var isUnviewedOnly: Boolean = false
fun withStoryId(storyId: Long): Builder {
this.storyId = storyId
@ -67,6 +69,11 @@ data class StoryViewerArgs(
return this
}
fun isUnviewedOnly(isUnviewedOnly: Boolean): Builder {
this.isUnviewedOnly = isUnviewedOnly
return this
}
fun build(): StoryViewerArgs {
return StoryViewerArgs(
recipientId = recipientId,
@ -77,7 +84,8 @@ data class StoryViewerArgs(
storyThumbBlur = storyThumbBlur,
recipientIds = recipientIds,
isFromNotification = isFromNotification,
groupReplyStartPosition = groupReplyStartPosition
groupReplyStartPosition = groupReplyStartPosition,
isUnviewedOnly = isUnviewedOnly
)
}
}

View file

@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.permissions.Permissions
@ -237,7 +238,8 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
storyThumbTextModel = text,
storyThumbUri = image,
storyThumbBlur = blur,
recipientIds = viewModel.getRecipientIds(model.data.isHidden)
recipientIds = viewModel.getRecipientIds(model.data.isHidden, model.data.storyViewState == StoryViewState.UNVIEWED),
isUnviewedOnly = model.data.storyViewState == StoryViewState.UNVIEWED
)
),
options.toBundle()

View file

@ -7,6 +7,7 @@ import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
@ -46,8 +47,11 @@ class StoriesLandingViewModel(private val storiesLandingRepository: StoriesLandi
store.update { it.copy(isHiddenContentVisible = isExpanded) }
}
fun getRecipientIds(hidden: Boolean): List<RecipientId> {
return store.state.storiesLandingItems.filter { it.isHidden == hidden }.map { it.storyRecipient.id }
fun getRecipientIds(hidden: Boolean, isUnviewed: Boolean): List<RecipientId> {
return store.state.storiesLandingItems
.filter { it.isHidden == hidden }
.filter { if (isUnviewed) it.storyViewState == StoryViewState.UNVIEWED else true }
.map { it.storyRecipient.id }
}
class Factory(private val storiesLandingRepository: StoriesLandingRepository) : ViewModelProvider.Factory {

View file

@ -34,8 +34,16 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
storyPager = view.findViewById(R.id.story_item_pager)
val adapter = StoryViewerPagerAdapter(this, storyViewerArgs.storyId, storyViewerArgs.isFromNotification, storyViewerArgs.groupReplyStartPosition)
val adapter = StoryViewerPagerAdapter(
this,
storyViewerArgs.storyId,
storyViewerArgs.isFromNotification,
storyViewerArgs.groupReplyStartPosition,
storyViewerArgs.isUnviewedOnly
)
storyPager.adapter = adapter
storyPager.overScrollMode = ViewPager2.OVER_SCROLL_NEVER
viewModel.isChildScrolling.observe(viewLifecycleOwner) {
storyPager.isUserInputEnabled = !it

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.stories.viewer
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment
@ -10,7 +11,8 @@ class StoryViewerPagerAdapter(
fragment: Fragment,
private val initialStoryId: Long,
private val isFromNotification: Boolean,
private val groupReplyStartPosition: Int
private val groupReplyStartPosition: Int,
private val isUnviewedOnly: Boolean
) : FragmentStateAdapter(fragment) {
private var pages: List<RecipientId> = emptyList()
@ -23,10 +25,15 @@ class StoryViewerPagerAdapter(
DiffUtil.calculateDiff(callback).dispatchUpdatesTo(this)
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_NEVER
}
override fun getItemCount(): Int = pages.size
override fun createFragment(position: Int): Fragment {
return StoryViewerPageFragment.create(pages[position], initialStoryId, isFromNotification, groupReplyStartPosition)
return StoryViewerPageFragment.create(pages[position], initialStoryId, isFromNotification, groupReplyStartPosition, isUnviewedOnly)
}
private class Callback(

View file

@ -4,6 +4,7 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@ -11,7 +12,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* Open for testing
*/
open class StoryViewerRepository {
fun getStories(hiddenStories: Boolean): Single<List<RecipientId>> {
fun getStories(hiddenStories: Boolean, unviewedOnly: Boolean): Single<List<RecipientId>> {
return Single.create<List<RecipientId>> { emitter ->
val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY)
val myStories = Recipient.resolved(myStoriesId)
@ -28,6 +29,16 @@ open class StoryViewerRepository {
} else {
!it.shouldHideStory()
}
}.filter {
if (unviewedOnly) {
if (it.isSelf || it.isMyStory) {
false
} else {
SignalDatabase.mms.getStoryViewState(it.id) == StoryViewState.UNVIEWED
}
} else {
true
}
}.map { it.id }
emitter.onSuccess(

View file

@ -70,7 +70,10 @@ class StoryViewerViewModel(
return if (storyViewerArgs.recipientIds.isNotEmpty()) {
Single.just(storyViewerArgs.recipientIds)
} else {
repository.getStories(storyViewerArgs.isInHiddenStoryMode)
repository.getStories(
hiddenStories = storyViewerArgs.isInHiddenStoryMode,
unviewedOnly = storyViewerArgs.isUnviewedOnly
)
}
}

View file

@ -95,7 +95,7 @@ class StoryViewerPageFragment :
private val viewModel: StoryViewerPageViewModel by viewModels(
factoryProducer = {
StoryViewerPageViewModel.Factory(storyRecipientId, initialStoryId, StoryViewerPageRepository(requireContext()))
StoryViewerPageViewModel.Factory(storyRecipientId, initialStoryId, isUnviewedOnly, StoryViewerPageRepository(requireContext()))
}
)
@ -120,6 +120,9 @@ class StoryViewerPageFragment :
private val groupReplyStartPosition: Int
get() = requireArguments().getInt(ARG_GROUP_REPLY_START_POSITION, -1)
private val isUnviewedOnly: Boolean
get() = requireArguments().getBoolean(ARG_IS_UNVIEWED_ONLY, false)
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = requireListener()
@ -832,14 +835,16 @@ class StoryViewerPageFragment :
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_UNVIEWED_ONLY = "is_unviewed_only"
fun create(recipientId: RecipientId, initialStoryId: Long, isFromNotification: Boolean, groupReplyStartPosition: Int): Fragment {
fun create(recipientId: RecipientId, initialStoryId: Long, isFromNotification: Boolean, groupReplyStartPosition: Int, isUnviewedOnly: Boolean): Fragment {
return StoryViewerPageFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_STORY_RECIPIENT_ID, recipientId)
putLong(ARG_STORY_ID, initialStoryId)
putBoolean(ARG_IS_FROM_NOTIFICATION, isFromNotification)
putInt(ARG_GROUP_REPLY_START_POSITION, groupReplyStartPosition)
putBoolean(ARG_IS_UNVIEWED_ONLY, isUnviewedOnly)
}
}
}

View file

@ -35,13 +35,15 @@ open class StoryViewerPageRepository(context: Context) {
private val context = context.applicationContext
private fun getStoryRecords(recipientId: RecipientId): Observable<List<MessageRecord>> {
private fun getStoryRecords(recipientId: RecipientId, isUnviewedOnly: Boolean): Observable<List<MessageRecord>> {
return Observable.create { emitter ->
val recipient = Recipient.resolved(recipientId)
fun refresh() {
val stories = if (recipient.isMyStory) {
SignalDatabase.mms.getAllOutgoingStories(false)
} else if (isUnviewedOnly) {
SignalDatabase.mms.getUnreadStories(recipientId, 100)
} else {
SignalDatabase.mms.getAllStoriesFor(recipientId)
}
@ -144,8 +146,8 @@ open class StoryViewerPageRepository(context: Context) {
return Stories.enqueueAttachmentsFromStoryForDownload(post.conversationMessage.messageRecord as MmsMessageRecord, true)
}
fun getStoryPostsFor(recipientId: RecipientId): Observable<List<StoryPost>> {
return getStoryRecords(recipientId)
fun getStoryPostsFor(recipientId: RecipientId, isUnviewedOnly: Boolean): Observable<List<StoryPost>> {
return getStoryRecords(recipientId, isUnviewedOnly)
.switchMap { records ->
val posts = records.map { getStoryPostFromRecord(recipientId, it) }
if (posts.isEmpty()) {

View file

@ -24,6 +24,7 @@ import kotlin.math.min
class StoryViewerPageViewModel(
private val recipientId: RecipientId,
private val initialStoryId: Long,
private val isUnviewedOnly: Boolean,
private val repository: StoryViewerPageRepository
) : ViewModel() {
@ -47,7 +48,7 @@ class StoryViewerPageViewModel(
fun refresh() {
disposables.clear()
disposables += repository.getStoryPostsFor(recipientId).subscribe { posts ->
disposables += repository.getStoryPostsFor(recipientId, isUnviewedOnly).subscribe { posts ->
store.update { state ->
var isDisplayingInitialState = false
val startIndex = if (state.posts.isEmpty() && initialStoryId > 0) {
@ -237,9 +238,14 @@ class StoryViewerPageViewModel(
return store.state.posts.getOrNull(index)
}
class Factory(private val recipientId: RecipientId, private val initialStoryId: Long, private val repository: StoryViewerPageRepository) : ViewModelProvider.Factory {
class Factory(
private val recipientId: RecipientId,
private val initialStoryId: Long,
private val isUnviewedOnly: Boolean,
private val repository: StoryViewerPageRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, repository)) as T
return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, isUnviewedOnly, repository)) as T
}
}
}

View file

@ -19,7 +19,7 @@ class RxStore<T : Any>(
private val actionSubject = PublishSubject.create<(T) -> T>().toSerialized()
val state: T get() = behaviorProcessor.value!!
val stateFlowable: Flowable<T> = behaviorProcessor
val stateFlowable: Flowable<T> = behaviorProcessor.onBackpressureLatest()
init {
actionSubject

View file

@ -35,7 +35,7 @@ class StoryViewerViewModelTest {
fun `Given a list of recipients, when I initialize, then I expect the list`() {
// GIVEN
val repoStories: List<RecipientId> = (1L..5L).map(RecipientId::from)
whenever(repository.getStories(any())).doReturn(Single.just(repoStories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(repoStories))
val injectedStories: List<RecipientId> = (6L..10L).map(RecipientId::from)
@ -51,7 +51,7 @@ class StoryViewerViewModelTest {
testScheduler.triggerActions()
// THEN
verify(repository, never()).getStories(any())
verify(repository, never()).getStories(any(), any())
assertEquals(injectedStories, testSubject.stateSnapshot.pages)
}
@ -60,7 +60,7 @@ class StoryViewerViewModelTest {
// GIVEN
val stories: List<RecipientId> = (1L..5L).map(RecipientId::from)
val startStory = RecipientId.from(2L)
whenever(repository.getStories(any())).doReturn(Single.just(stories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(stories))
// WHEN
val testSubject = StoryViewerViewModel(
@ -84,7 +84,7 @@ class StoryViewerViewModelTest {
// GIVEN
val stories: List<RecipientId> = (1L..5L).map(RecipientId::from)
val startStory = RecipientId.from(1L)
whenever(repository.getStories(any())).doReturn(Single.just(stories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(stories))
val testSubject = StoryViewerViewModel(
StoryViewerArgs(
recipientId = startStory,
@ -110,7 +110,7 @@ class StoryViewerViewModelTest {
// GIVEN
val stories: List<RecipientId> = (1L..5L).map(RecipientId::from)
val startStory = stories.last()
whenever(repository.getStories(any())).doReturn(Single.just(stories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(stories))
val testSubject = StoryViewerViewModel(
StoryViewerArgs(
recipientId = startStory,
@ -136,7 +136,7 @@ class StoryViewerViewModelTest {
// GIVEN
val stories: List<RecipientId> = (1L..5L).map(RecipientId::from)
val startStory = stories.last()
whenever(repository.getStories(any())).doReturn(Single.just(stories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(stories))
val testSubject = StoryViewerViewModel(
StoryViewerArgs(
recipientId = startStory,
@ -162,7 +162,7 @@ class StoryViewerViewModelTest {
// GIVEN
val stories: List<RecipientId> = (1L..5L).map(RecipientId::from)
val startStory = stories.first()
whenever(repository.getStories(any())).doReturn(Single.just(stories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(stories))
val testSubject = StoryViewerViewModel(
StoryViewerArgs(
recipientId = startStory,
@ -188,7 +188,7 @@ class StoryViewerViewModelTest {
// GIVEN
val stories: List<RecipientId> = (1L..5L).map(RecipientId::from)
val startStory = stories.first()
whenever(repository.getStories(any())).doReturn(Single.just(stories))
whenever(repository.getStories(any(), any())).doReturn(Single.just(stories))
val testSubject = StoryViewerViewModel(
StoryViewerArgs(
recipientId = startStory,

View file

@ -42,7 +42,7 @@ class StoryViewerPageViewModelTest {
fun `Given first page and first post, when I goToPreviousPost, then I expect storyIndex to be 0`() {
// GIVEN
val storyPosts = createStoryPosts(3) { true }
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
val testSubject = createTestSubject()
testSubject.setIsFirstPage(true)
testScheduler.triggerActions()
@ -61,7 +61,7 @@ class StoryViewerPageViewModelTest {
fun `Given first page and second post, when I goToPreviousPost, then I expect storyIndex to be 0`() {
// GIVEN
val storyPosts = createStoryPosts(3) { true }
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
val testSubject = createTestSubject()
testSubject.setIsFirstPage(true)
testScheduler.triggerActions()
@ -82,7 +82,7 @@ class StoryViewerPageViewModelTest {
fun `Given no initial story and 3 records all viewed, when I initialize, then I expect storyIndex to be 0`() {
// GIVEN
val storyPosts = createStoryPosts(3) { true }
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
// WHEN
val testSubject = createTestSubject()
@ -98,7 +98,7 @@ class StoryViewerPageViewModelTest {
fun `Given no initial story and 3 records all not viewed, when I initialize, then I expect storyIndex to be 0`() {
// GIVEN
val storyPosts = createStoryPosts(3) { false }
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
// WHEN
val testSubject = createTestSubject()
@ -114,7 +114,7 @@ class StoryViewerPageViewModelTest {
fun `Given no initial story and 3 records with 2nd is not viewed, when I initialize, then I expect storyIndex to be 1`() {
// GIVEN
val storyPosts = createStoryPosts(3) { it % 2 != 0 }
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
// WHEN
val testSubject = createTestSubject()
@ -130,7 +130,7 @@ class StoryViewerPageViewModelTest {
fun `Given no initial story and 3 records with 1st and 3rd not viewed, when I goToNext, then I expect storyIndex to be 2`() {
// GIVEN
val storyPosts = createStoryPosts(3) { it % 2 == 0 }
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
// WHEN
val testSubject = createTestSubject()
@ -148,7 +148,7 @@ class StoryViewerPageViewModelTest {
fun `Given a single story, when I goToPrevious, then I expect storyIndex to be -1`() {
// GIVEN
val storyPosts = createStoryPosts(1)
whenever(repository.getStoryPostsFor(any())).thenReturn(Observable.just(storyPosts))
whenever(repository.getStoryPostsFor(any(), any())).thenReturn(Observable.just(storyPosts))
// WHEN
val testSubject = createTestSubject()
@ -166,6 +166,7 @@ class StoryViewerPageViewModelTest {
return StoryViewerPageViewModel(
RecipientId.from(1),
-1L,
false,
repository
)
}