Signal-Android/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt

333 wiersze
12 KiB
Kotlin

package org.thoughtcrime.securesms.stories.viewer.page
import androidx.annotation.CheckResult
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Optional
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.min
/**
* Encapsulates presentation logic for displaying a collection of posts from a given user's story
*/
class StoryViewerPageViewModel(
private val args: StoryViewerPageArgs,
private val repository: StoryViewerPageRepository,
val storyCache: StoryCache
) : ViewModel() {
private val store = RxStore(StoryViewerPageState(isReceiptsEnabled = repository.isReadReceiptsEnabled()))
private val disposables = CompositeDisposable()
private val storyViewerDialogSubject: Subject<Optional<StoryViewerDialog>> = PublishSubject.create()
private val storyLongPressSubject: Subject<Boolean> = PublishSubject.create()
private val storyViewerPlaybackStore = Store(StoryViewerPlaybackState())
val storyViewerPlaybackState: LiveData<StoryViewerPlaybackState> = storyViewerPlaybackStore.stateLiveData
val groupDirectReplyObservable: Observable<Optional<StoryViewerDialog>> = storyViewerDialogSubject
val state: Flowable<StoryViewerPageState> = store.stateFlowable
val postContent: Flowable<Optional<StoryPost.Content>> = store.stateFlowable.map {
Optional.ofNullable(it.posts.getOrNull(it.selectedPostIndex)?.content)
}
fun getStateSnapshot(): StoryViewerPageState = store.state
init {
refresh()
}
fun checkReadReceiptState() {
val isReceiptsEnabledInState = getStateSnapshot().isReceiptsEnabled
val isReceiptsEnabledInRepository = repository.isReadReceiptsEnabled()
if (isReceiptsEnabledInState xor isReceiptsEnabledInRepository) {
store.update {
it.copy(isReceiptsEnabled = isReceiptsEnabledInRepository)
}
}
}
fun refresh() {
disposables.clear()
disposables += repository.getStoryPostsFor(args.recipientId, args.isOutgoingOnly).subscribe { posts ->
store.update { state ->
val isDisplayingInitialState = state.posts.isEmpty() && posts.isNotEmpty()
val startIndex = if (state.posts.isEmpty() && args.initialStoryId > 0) {
val initialIndex = posts.indexOfFirst { it.id == args.initialStoryId }
initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex
} else if (state.posts.isEmpty()) {
val initialPost = getNextUnreadPost(posts)
val initialIndex = initialPost?.let { posts.indexOf(it) } ?: -1
initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex
} else {
state.selectedPostIndex
}
state.copy(
isReady = true,
posts = posts,
replyState = resolveSwipeToReplyState(state, startIndex),
selectedPostIndex = startIndex,
isDisplayingInitialState = isDisplayingInitialState
)
}
val attachments: List<Attachment> = posts.map { it.content }
.filterIsInstance<StoryPost.Content.AttachmentContent>()
.map { it.attachment }
if (attachments.isNotEmpty()) {
storyCache.prefetch(attachments)
}
}
disposables += storyLongPressSubject.debounce(150, TimeUnit.MILLISECONDS).subscribe { isLongPress ->
storyViewerPlaybackStore.update { it.copy(isUserLongTouching = isLongPress) }
}
}
override fun onCleared() {
disposables.clear()
storyCache.clear()
store.dispose()
}
fun hideStory(): Completable {
return repository.hideStory(args.recipientId)
}
fun markViewed(storyPost: StoryPost) {
repository.markViewed(storyPost)
}
fun setSelectedPostIndex(index: Int) {
val selectedPost = getPostAt(index)
if (selectedPost != null && selectedPost.content.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) {
disposables += repository.forceDownload(selectedPost).subscribe()
}
store.update {
it.copy(
selectedPostIndex = index,
replyState = resolveSwipeToReplyState(it, index)
)
}
}
fun goToNextPost() {
if (store.state.posts.isEmpty()) {
return
}
val postIndex = store.state.selectedPostIndex
val nextUnreadPost: StoryPost? = getNextUnreadPost(store.state.posts.drop(postIndex + 1))
when {
nextUnreadPost == null && args.isJumpForwardToUnviewed -> setSelectedPostIndex(store.state.posts.size)
nextUnreadPost == null -> setSelectedPostIndex(postIndex + 1)
else -> setSelectedPostIndex(store.state.posts.indexOf(nextUnreadPost))
}
}
fun goToPreviousPost() {
if (store.state.posts.isEmpty()) {
return
}
val postIndex = store.state.selectedPostIndex
val minIndex = if (store.state.isFirstPage) 0 else -1
setSelectedPostIndex(max(minIndex, postIndex - 1))
}
fun getRestartIndex(): Int {
return min(store.state.selectedPostIndex, store.state.posts.lastIndex)
}
fun getSwipeToReplyState(): StoryViewerPageState.ReplyState {
return store.state.replyState
}
fun hasPost(): Boolean {
return store.state.selectedPostIndex in store.state.posts.indices
}
fun getPost(): StoryPost? {
return if (hasPost()) {
store.state.posts[store.state.selectedPostIndex]
} else {
null
}
}
fun requirePost(): StoryPost {
return getPost()!!
}
fun forceDownloadSelectedPost() {
disposables += repository.forceDownload(requirePost()).subscribe()
}
fun startDirectReply(storyId: Long, recipientId: RecipientId) {
storyViewerDialogSubject.onNext(Optional.of(StoryViewerDialog.GroupDirectReply(recipientId, storyId)))
}
fun setIsFragmentResumed(isFragmentResumed: Boolean) {
storyViewerPlaybackStore.update { it.copy(isFragmentResumed = isFragmentResumed) }
}
fun setIsUserScrollingParent(isUserScrollingParent: Boolean) {
storyViewerPlaybackStore.update { it.copy(isUserScrollingParent = isUserScrollingParent) }
}
fun setIsUserScrollingChild(isUserScrollingChild: Boolean) {
storyViewerPlaybackStore.update { it.copy(isUserScrollingChild = isUserScrollingChild) }
}
fun setIsDisplayingSlate(isDisplayingSlate: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingSlate = isDisplayingSlate) }
}
fun setIsFirstPage(isFirstPage: Boolean) {
store.update { it.copy(isFirstPage = isFirstPage) }
}
fun setIsSelectedPage(isSelectedPage: Boolean) {
storyViewerPlaybackStore.update { it.copy(isSelectedPage = isSelectedPage) }
}
fun setIsDisplayingReactionAnimation(isDisplayingReactionAnimation: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingReactionAnimation = isDisplayingReactionAnimation) }
}
fun setIsDisplayingContextMenu(isDisplayingContextMenu: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingContextMenu = isDisplayingContextMenu) }
}
fun setIsDisplayingForwardDialog(isDisplayingForwardDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingForwardDialog = isDisplayingForwardDialog) }
}
fun setIsDisplayingDeleteDialog(isDisplayingDeleteDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingDeleteDialog = isDisplayingDeleteDialog) }
}
fun setIsDisplayingHideDialog(isDisplayingHideDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingHideDialog = isDisplayingHideDialog) }
}
fun setIsDisplayingViewsAndRepliesDialog(isDisplayingViewsAndRepliesDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingViewsAndRepliesDialog = isDisplayingViewsAndRepliesDialog) }
}
fun setIsDisplayingDirectReplyDialog(isDisplayingDirectReplyDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingDirectReplyDialog = isDisplayingDirectReplyDialog) }
}
fun setIsDisplayingCaptionOverlay(isDisplayingCaptionOverlay: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingCaptionOverlay = isDisplayingCaptionOverlay) }
}
fun setIsDisplayingRecipientBottomSheet(isDisplayingRecipientBottomSheet: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingRecipientBottomSheet = isDisplayingRecipientBottomSheet) }
}
fun setIsUserTouching(isUserTouching: Boolean) {
storyViewerPlaybackStore.update { it.copy(isUserTouching = isUserTouching) }
storyLongPressSubject.onNext(isUserTouching)
}
fun setAreSegmentsInitialized(areSegmentsInitialized: Boolean) {
storyViewerPlaybackStore.update { it.copy(areSegmentsInitialized = areSegmentsInitialized) }
}
fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingLinkPreviewTooltip = isDisplayingLinkPreviewTooltip) }
}
fun setIsRunningSharedElementAnimation(isRunningSharedElementAnimation: Boolean) {
storyViewerPlaybackStore.update { it.copy(isRunningSharedElementAnimation = isRunningSharedElementAnimation) }
}
fun setIsDisplayingFirstTimeNavigation(isDisplayingFirstTimeNavigation: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingFirstTimeNavigation = isDisplayingFirstTimeNavigation) }
}
fun setIsDisplayingInfoDialog(isDisplayingInfoDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingInfoDialog = isDisplayingInfoDialog) }
}
fun setIsUserScaling(isUserScaling: Boolean) {
storyViewerPlaybackStore.update { it.copy(isUserScaling = isUserScaling) }
}
fun setIsDisplayingPartialSendDialog(isDisplayingPartialSendDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingPartialSendDialog = isDisplayingPartialSendDialog) }
}
private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int): StoryViewerPageState.ReplyState {
if (index !in state.posts.indices) {
return StoryViewerPageState.ReplyState.NONE
}
val post = state.posts[index]
val message = post.conversationMessage.messageRecord
val isFromSelf = post.sender.isSelf
val isToGroup = post.group != null
val isFailed = message.isFailed
val isPartialSend = message.isIdentityMismatchFailure
val isInProgress = !post.conversationMessage.messageRecord.isSent
return when {
isFromSelf && isPartialSend -> StoryViewerPageState.ReplyState.PARTIAL_SEND
isFromSelf && isFailed -> StoryViewerPageState.ReplyState.SEND_FAILURE
isFromSelf && isInProgress -> StoryViewerPageState.ReplyState.SENDING
post.allowsReplies -> StoryViewerPageState.ReplyState.resolve(isFromSelf, isToGroup)
isFromSelf -> StoryViewerPageState.ReplyState.SELF
else -> StoryViewerPageState.ReplyState.NONE
}
}
private fun getNextUnreadPost(list: List<StoryPost>): StoryPost? {
return list.firstOrNull { !it.hasSelfViewed }
}
fun getPostAt(index: Int): StoryPost? {
return store.state.posts.getOrNull(index)
}
@CheckResult
fun resend(storyPost: StoryPost): Completable {
return repository
.resend(storyPost.conversationMessage.messageRecord)
.observeOn(AndroidSchedulers.mainThread())
}
class Factory(
private val args: StoryViewerPageArgs,
private val repository: StoryViewerPageRepository,
private val storyCache: StoryCache
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StoryViewerPageViewModel(args, repository, storyCache)) as T
}
}
}