2022-01-11 19:00:29 +01:00
|
|
|
/* Copyright 2021 Tusky Contributors
|
|
|
|
*
|
|
|
|
* This file is a part of Tusky.
|
|
|
|
*
|
|
|
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
|
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
|
|
* License, or (at your option) any later version.
|
|
|
|
*
|
|
|
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
|
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
|
|
* Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
|
|
* see <http://www.gnu.org/licenses>. */
|
|
|
|
|
|
|
|
package com.keylesspalace.tusky.components.timeline.viewmodel
|
|
|
|
|
|
|
|
import android.content.SharedPreferences
|
|
|
|
import android.util.Log
|
|
|
|
import androidx.lifecycle.viewModelScope
|
|
|
|
import androidx.paging.ExperimentalPagingApi
|
|
|
|
import androidx.paging.Pager
|
|
|
|
import androidx.paging.PagingConfig
|
|
|
|
import androidx.paging.cachedIn
|
|
|
|
import androidx.paging.filter
|
|
|
|
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
|
|
|
import com.keylesspalace.tusky.appstore.EventHub
|
|
|
|
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
|
|
|
import com.keylesspalace.tusky.appstore.PinEvent
|
|
|
|
import com.keylesspalace.tusky.appstore.ReblogEvent
|
2022-03-08 21:39:59 +01:00
|
|
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
2022-01-11 19:00:29 +01:00
|
|
|
import com.keylesspalace.tusky.db.AccountManager
|
|
|
|
import com.keylesspalace.tusky.entity.Poll
|
|
|
|
import com.keylesspalace.tusky.entity.Status
|
|
|
|
import com.keylesspalace.tusky.network.FilterModel
|
|
|
|
import com.keylesspalace.tusky.network.MastodonApi
|
|
|
|
import com.keylesspalace.tusky.network.TimelineCases
|
2022-02-25 18:56:21 +01:00
|
|
|
import com.keylesspalace.tusky.util.getDomain
|
2022-03-12 09:38:48 +01:00
|
|
|
import com.keylesspalace.tusky.util.isLessThan
|
|
|
|
import com.keylesspalace.tusky.util.isLessThanOrEqual
|
2022-01-11 19:00:29 +01:00
|
|
|
import com.keylesspalace.tusky.util.toViewData
|
|
|
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
2022-04-15 13:20:27 +02:00
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
import kotlinx.coroutines.asExecutor
|
|
|
|
import kotlinx.coroutines.flow.flowOn
|
2022-01-11 19:00:29 +01:00
|
|
|
import kotlinx.coroutines.flow.map
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
import kotlinx.coroutines.rx3.await
|
|
|
|
import retrofit2.HttpException
|
|
|
|
import retrofit2.Response
|
2022-03-08 21:39:59 +01:00
|
|
|
import java.io.IOException
|
2022-01-11 19:00:29 +01:00
|
|
|
import javax.inject.Inject
|
|
|
|
|
|
|
|
/**
|
|
|
|
* TimelineViewModel that caches all statuses in an in-memory list
|
|
|
|
*/
|
|
|
|
class NetworkTimelineViewModel @Inject constructor(
|
|
|
|
timelineCases: TimelineCases,
|
|
|
|
private val api: MastodonApi,
|
|
|
|
eventHub: EventHub,
|
|
|
|
accountManager: AccountManager,
|
|
|
|
sharedPreferences: SharedPreferences,
|
|
|
|
filterModel: FilterModel
|
|
|
|
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
|
|
|
|
|
|
|
|
var currentSource: NetworkTimelinePagingSource? = null
|
|
|
|
|
|
|
|
val statusData: MutableList<StatusViewData> = mutableListOf()
|
|
|
|
|
|
|
|
var nextKey: String? = null
|
|
|
|
|
2022-01-20 21:10:32 +01:00
|
|
|
@OptIn(ExperimentalPagingApi::class)
|
2022-01-11 19:00:29 +01:00
|
|
|
override val statuses = Pager(
|
|
|
|
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
|
|
|
pagingSourceFactory = {
|
|
|
|
NetworkTimelinePagingSource(
|
|
|
|
viewModel = this
|
|
|
|
).also { source ->
|
|
|
|
currentSource = source
|
|
|
|
}
|
|
|
|
},
|
|
|
|
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
|
|
|
|
).flow
|
|
|
|
.map { pagingData ->
|
2022-04-15 13:20:27 +02:00
|
|
|
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
|
2022-01-11 19:00:29 +01:00
|
|
|
!shouldFilterStatus(statusViewData)
|
|
|
|
}
|
|
|
|
}
|
2022-04-15 13:20:27 +02:00
|
|
|
.flowOn(Dispatchers.Default)
|
2022-01-11 19:00:29 +01:00
|
|
|
.cachedIn(viewModelScope)
|
|
|
|
|
|
|
|
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
|
|
|
status.copy(
|
|
|
|
status = status.status.copy(poll = newPoll)
|
|
|
|
).update()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
|
|
|
status.copy(
|
|
|
|
isExpanded = expanded
|
|
|
|
).update()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
|
|
|
status.copy(
|
|
|
|
isShowingContent = isShowing
|
|
|
|
).update()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
|
|
|
status.copy(
|
|
|
|
isCollapsed = isCollapsed
|
|
|
|
).update()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun removeAllByAccountId(accountId: String) {
|
|
|
|
statusData.removeAll { vd ->
|
|
|
|
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
|
|
|
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
|
|
|
}
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun removeAllByInstance(instance: String) {
|
|
|
|
statusData.removeAll { vd ->
|
|
|
|
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
2022-02-25 18:56:21 +01:00
|
|
|
getDomain(status.account.url) == instance
|
2022-01-11 19:00:29 +01:00
|
|
|
}
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun removeStatusWithId(id: String) {
|
|
|
|
statusData.removeAll { vd ->
|
|
|
|
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
|
|
|
status.id == id || status.reblog?.id == id
|
|
|
|
}
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun loadMore(placeholderId: String) {
|
|
|
|
viewModelScope.launch {
|
|
|
|
try {
|
2022-03-12 09:38:48 +01:00
|
|
|
val placeholderIndex =
|
|
|
|
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
|
|
|
|
statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true)
|
|
|
|
|
2022-03-28 18:39:16 +02:00
|
|
|
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
|
|
|
|
|
2022-01-11 19:00:29 +01:00
|
|
|
val statusResponse = fetchStatusesForKind(
|
2022-03-28 18:39:16 +02:00
|
|
|
fromId = idAbovePlaceholder,
|
2022-01-11 19:00:29 +01:00
|
|
|
uptoId = null,
|
|
|
|
limit = 20
|
|
|
|
)
|
|
|
|
|
|
|
|
val statuses = statusResponse.body()
|
|
|
|
if (!statusResponse.isSuccessful || statuses == null) {
|
|
|
|
loadMoreFailed(placeholderId, HttpException(statusResponse))
|
|
|
|
return@launch
|
|
|
|
}
|
|
|
|
|
2022-03-12 09:38:48 +01:00
|
|
|
statusData.removeAt(placeholderIndex)
|
2022-01-11 19:00:29 +01:00
|
|
|
|
2022-03-12 09:38:48 +01:00
|
|
|
val activeAccount = accountManager.activeAccount!!
|
2022-03-28 18:39:16 +02:00
|
|
|
val data: MutableList<StatusViewData> = statuses.map { status ->
|
2022-01-11 19:00:29 +01:00
|
|
|
status.toViewData(
|
2022-03-12 09:38:48 +01:00
|
|
|
isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
|
|
|
isExpanded = activeAccount.alwaysOpenSpoiler,
|
|
|
|
isCollapsed = true
|
2022-01-11 19:00:29 +01:00
|
|
|
)
|
2022-03-12 09:38:48 +01:00
|
|
|
}.toMutableList()
|
|
|
|
|
|
|
|
if (statuses.isNotEmpty()) {
|
|
|
|
val firstId = statuses.first().id
|
|
|
|
val lastId = statuses.last().id
|
|
|
|
val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false }
|
|
|
|
val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false }
|
|
|
|
|
|
|
|
if (overlappedFrom < overlappedTo) {
|
|
|
|
data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() }
|
|
|
|
.filter { (_, oldStatus) -> oldStatus != null }
|
|
|
|
.forEach { (i, oldStatus) ->
|
2022-03-28 18:39:16 +02:00
|
|
|
data[i] = data[i].asStatusOrNull()!!
|
2022-03-12 09:38:48 +01:00
|
|
|
.copy(
|
|
|
|
isShowingContent = oldStatus!!.isShowingContent,
|
|
|
|
isExpanded = oldStatus.isExpanded,
|
|
|
|
isCollapsed = oldStatus.isCollapsed,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
statusData.removeAll { status ->
|
|
|
|
when (status) {
|
|
|
|
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
|
|
|
|
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
2022-03-28 18:39:16 +02:00
|
|
|
data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false)
|
2022-03-12 09:38:48 +01:00
|
|
|
}
|
2022-01-11 19:00:29 +01:00
|
|
|
}
|
|
|
|
|
2022-03-12 09:38:48 +01:00
|
|
|
statusData.addAll(placeholderIndex, data)
|
2022-01-11 19:00:29 +01:00
|
|
|
|
|
|
|
currentSource?.invalidate()
|
|
|
|
} catch (e: Exception) {
|
2022-03-08 21:39:59 +01:00
|
|
|
ifExpected(e) {
|
|
|
|
loadMoreFailed(placeholderId, e)
|
|
|
|
}
|
2022-01-11 19:00:29 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun loadMoreFailed(placeholderId: String, e: Exception) {
|
|
|
|
Log.w("NetworkTimelineVM", "failed loading statuses", e)
|
|
|
|
|
|
|
|
val index =
|
|
|
|
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
|
|
|
|
statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false)
|
|
|
|
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
|
|
|
updateStatusById(reblogEvent.statusId) {
|
|
|
|
it.copy(status = it.status.copy(reblogged = reblogEvent.reblog))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun handleFavEvent(favEvent: FavoriteEvent) {
|
|
|
|
updateActionableStatusById(favEvent.statusId) {
|
|
|
|
it.copy(favourited = favEvent.favourite)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
|
|
|
|
updateActionableStatusById(bookmarkEvent.statusId) {
|
|
|
|
it.copy(bookmarked = bookmarkEvent.bookmark)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun handlePinEvent(pinEvent: PinEvent) {
|
|
|
|
updateActionableStatusById(pinEvent.statusId) {
|
|
|
|
it.copy(pinned = pinEvent.pinned)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun fullReload() {
|
2022-03-28 18:39:16 +02:00
|
|
|
nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id
|
2022-01-11 19:00:29 +01:00
|
|
|
statusData.clear()
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
|
2022-03-08 21:39:59 +01:00
|
|
|
@Throws(IOException::class, HttpException::class)
|
2022-01-11 19:00:29 +01:00
|
|
|
suspend fun fetchStatusesForKind(
|
|
|
|
fromId: String?,
|
|
|
|
uptoId: String?,
|
|
|
|
limit: Int
|
|
|
|
): Response<List<Status>> {
|
|
|
|
return when (kind) {
|
|
|
|
Kind.HOME -> api.homeTimeline(fromId, uptoId, limit)
|
|
|
|
Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit)
|
|
|
|
Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit)
|
|
|
|
Kind.TAG -> {
|
|
|
|
val firstHashtag = tags[0]
|
|
|
|
val additionalHashtags = tags.subList(1, tags.size)
|
|
|
|
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit)
|
|
|
|
}
|
|
|
|
Kind.USER -> api.accountStatuses(
|
|
|
|
id!!,
|
|
|
|
fromId,
|
|
|
|
uptoId,
|
|
|
|
limit,
|
|
|
|
excludeReplies = true,
|
|
|
|
onlyMedia = null,
|
|
|
|
pinned = null
|
|
|
|
)
|
|
|
|
Kind.USER_PINNED -> api.accountStatuses(
|
|
|
|
id!!,
|
|
|
|
fromId,
|
|
|
|
uptoId,
|
|
|
|
limit,
|
|
|
|
excludeReplies = null,
|
|
|
|
onlyMedia = null,
|
|
|
|
pinned = true
|
|
|
|
)
|
|
|
|
Kind.USER_WITH_REPLIES -> api.accountStatuses(
|
|
|
|
id!!,
|
|
|
|
fromId,
|
|
|
|
uptoId,
|
|
|
|
limit,
|
|
|
|
excludeReplies = null,
|
|
|
|
onlyMedia = null,
|
|
|
|
pinned = null
|
|
|
|
)
|
|
|
|
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
|
|
|
|
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
|
|
|
|
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
|
|
|
|
}.await()
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun StatusViewData.Concrete.update() {
|
|
|
|
val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id }
|
|
|
|
statusData[position] = this
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
|
|
|
|
private inline fun updateStatusById(
|
|
|
|
id: String,
|
|
|
|
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
|
|
|
) {
|
|
|
|
val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id }
|
|
|
|
if (pos == -1) return
|
|
|
|
updateViewDataAt(pos, updater)
|
|
|
|
}
|
|
|
|
|
|
|
|
private inline fun updateActionableStatusById(
|
|
|
|
id: String,
|
|
|
|
updater: (Status) -> Status
|
|
|
|
) {
|
|
|
|
val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id }
|
|
|
|
if (pos == -1) return
|
|
|
|
updateViewDataAt(pos) { vd ->
|
|
|
|
if (vd.status.reblog != null) {
|
|
|
|
vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog)))
|
|
|
|
} else {
|
|
|
|
vd.copy(status = updater(vd.status))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private inline fun updateViewDataAt(
|
|
|
|
position: Int,
|
|
|
|
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
|
|
|
) {
|
|
|
|
val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return
|
|
|
|
statusData[position] = updater(status)
|
|
|
|
currentSource?.invalidate()
|
|
|
|
}
|
|
|
|
}
|