migrate to paging 3 (#2182)

* migrate conversations and search to paging 3

* delete SearchRepository

* remove unneeded executor from search

* fix bugs in conversations

* update license headers

* fix conversations refreshing

* fix search refresh indicators

* show fullscreen loading while conversations are empty

* search bugfixes

* error handling

* error handling

* remove mastodon bug workaround

* update ConversationsFragment

* fix conversations more menu and deleting conversations

* delete unused class

* catch exceptions in ConversationsViewModel

* fix bug where items are not diffed correctly / cleanup code

* fix search progressbar display conditions
This commit is contained in:
Konrad Pozniak 2021-06-17 18:54:56 +02:00 committed by GitHub
commit 6d4f5ad027
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1612 additions and 1022 deletions

View file

@ -1,3 +1,18 @@
/* 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.search
enum class SearchType(val apiParameter: String) {

View file

@ -1,17 +1,35 @@
/* 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.search
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PagedList
import com.keylesspalace.tusky.components.search.adapter.SearchRepository
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
@ -35,82 +53,62 @@ class SearchViewModel @Inject constructor(
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
private val statusesRepository =
SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
private val accountsRepository = SearchRepository<Account>(mastodonApi)
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> =
repoResultStatus.switchMap { it.pagedList }
val networkStateStatus: LiveData<NetworkState> = repoResultStatus.switchMap { it.networkState }
val networkStateStatusRefresh: LiveData<NetworkState> =
repoResultStatus.switchMap { it.refreshState }
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) }
.apply {
loadedStatuses.addAll(this)
}
}
private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) {
it.accounts
}
private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) {
it.hashtags
}
private val repoResultAccount = MutableLiveData<Listing<Account>>()
val accounts: LiveData<PagedList<Account>> = repoResultAccount.switchMap { it.pagedList }
val networkStateAccount: LiveData<NetworkState> =
repoResultAccount.switchMap { it.networkState }
val networkStateAccountRefresh: LiveData<NetworkState> =
repoResultAccount.switchMap { it.refreshState }
val statusesFlow = Pager(
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
pagingSourceFactory = statusesPagingSourceFactory
).flow
.cachedIn(viewModelScope)
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
val hashtags: LiveData<PagedList<HashTag>> = repoResultHashTag.switchMap { it.pagedList }
val networkStateHashTag: LiveData<NetworkState> =
repoResultHashTag.switchMap { it.networkState }
val networkStateHashTagRefresh: LiveData<NetworkState> =
repoResultHashTag.switchMap { it.refreshState }
val accountsFlow = Pager(
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
pagingSourceFactory = accountsPagingSourceFactory
).flow
.cachedIn(viewModelScope)
val hashtagsFlow = Pager(
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
pagingSourceFactory = hashtagsPagingSourceFactory
).flow
.cachedIn(viewModelScope)
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
fun search(query: String) {
loadedStatuses.clear()
repoResultStatus.value = statusesRepository.getSearchData(
SearchType.Status,
query,
disposables,
initialItems = loadedStatuses
) {
it?.statuses?.map { status ->
Pair(
status,
status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)
)
}
.orEmpty()
.apply {
loadedStatuses.addAll(this)
}
}
repoResultAccount.value =
accountsRepository.getSearchData(SearchType.Account, query, disposables) {
it?.accounts.orEmpty()
}
val hashtagQuery = if (query.startsWith("#")) query else "#$query"
repoResultHashTag.value =
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
it?.hashtags.orEmpty()
}
statusesPagingSourceFactory.newSearch(query)
accountsPagingSourceFactory.newSearch(query)
hashtagsPagingSourceFactory.newSearch(query)
}
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
timelineCases.delete(status.first.id)
.subscribe({
if (loadedStatuses.remove(status))
repoResultStatus.value?.refresh?.invoke()
}, { err ->
Log.d(TAG, "Failed to delete status", err)
})
.autoDispose()
.subscribe({
if (loadedStatuses.remove(status))
statusesPagingSourceFactory.invalidate()
}, {
err -> Log.d(TAG, "Failed to delete status", err)
})
.autoDispose()
}
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newPair = Pair(status.first, status.second.copy(isExpanded = expanded))
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
statusesPagingSourceFactory.invalidate()
}
}
@ -119,36 +117,30 @@ class SearchViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ setRebloggedForStatus(status, reblog) },
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
{ t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) }
)
.autoDispose()
}
private fun setRebloggedForStatus(
status: Pair<Status, StatusViewData.Concrete>,
reblog: Boolean
) {
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
status.first.reblogged = reblog
status.first.reblog?.reblogged = reblog
repoResultStatus.value?.refresh?.invoke()
statusesPagingSourceFactory.invalidate()
}
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing))
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
statusesPagingSourceFactory.invalidate()
}
}
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed))
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
statusesPagingSourceFactory.invalidate()
}
}
@ -159,12 +151,7 @@ class SearchViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ newPoll -> updateStatus(status, newPoll) },
{ t ->
Log.d(
TAG,
"Failed to vote in poll: ${status.first.id}", t
)
}
{ t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) }
)
.autoDispose()
}
@ -175,13 +162,13 @@ class SearchViewModel @Inject constructor(
val newStatus = status.first.copy(poll = newPoll)
val newViewData = status.second.copy(status = newStatus)
loadedStatuses[idx] = Pair(newStatus, newViewData)
repoResultStatus.value?.refresh?.invoke()
statusesPagingSourceFactory.invalidate()
}
}
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
status.first.favourited = isFavorited
repoResultStatus.value?.refresh?.invoke()
statusesPagingSourceFactory.invalidate()
timelineCases.favourite(status.first.id, isFavorited)
.onErrorReturnItem(status.first)
.subscribe()
@ -190,7 +177,7 @@ class SearchViewModel @Inject constructor(
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
status.first.bookmarked = isBookmarked
repoResultStatus.value?.refresh?.invoke()
statusesPagingSourceFactory.invalidate()
timelineCases.bookmark(status.first.id, isBookmarked)
.onErrorReturnItem(status.first)
.subscribe()
@ -217,10 +204,6 @@ class SearchViewModel @Inject constructor(
return timelineCases.delete(id)
}
fun retryAllSearches() {
search(currentQuery)
}
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
@ -230,7 +213,7 @@ class SearchViewModel @Inject constructor(
status.second.copy(status = newStatus)
)
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
statusesPagingSourceFactory.invalidate()
}
timelineCases.muteConversation(status.first.id, mute)
.onErrorReturnItem(status.first)
@ -240,5 +223,6 @@ class SearchViewModel @Inject constructor(
companion object {
private const val TAG = "SearchViewModel"
private const val DEFAULT_LOAD_SIZE = 20
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -17,26 +17,25 @@ package com.keylesspalace.tusky.components.search.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.LinkListener
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean)
: PagedListAdapter<Account, RecyclerView.ViewHolder>(ACCOUNT_COMPARATOR) {
: PagingDataAdapter<Account, AccountViewHolder>(ACCOUNT_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_account, parent, false)
return AccountViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
getItem(position)?.let { item ->
(holder as AccountViewHolder).apply {
holder.apply {
setupWithAccount(item, animateAvatars, animateEmojis)
setupLinkListener(linkListener)
}
@ -52,7 +51,5 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
oldItem.id == newItem.id
}
}
}
}

View file

@ -1,126 +0,0 @@
/* Copyright 2019 Joel Pyska
*
* 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.search.adapter
import androidx.lifecycle.MutableLiveData
import androidx.paging.PositionalDataSource
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.NetworkState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo
import java.util.concurrent.Executor
class SearchDataSource<T>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val searchRequest: String,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor,
private val initialItems: List<T>? = null,
private val parser: (SearchResult?) -> List<T>,
private val source: SearchDataSourceFactory<T>) : PositionalDataSource<T>() {
val networkState = MutableLiveData<NetworkState>()
private var retry: (() -> Any)? = null
val initialLoad = MutableLiveData<NetworkState>()
fun retry() {
retry?.let {
retryExecutor.execute {
it.invoke()
}
}
}
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<T>) {
if (!initialItems.isNullOrEmpty()) {
callback.onResult(initialItems.toList(), 0)
} else {
networkState.postValue(NetworkState.LOADED)
retry = null
initialLoad.postValue(NetworkState.LOADING)
mastodonApi.searchObservable(
query = searchRequest,
type = searchType.apiParameter,
resolve = true,
limit = params.requestedLoadSize,
offset = 0,
following = false)
.subscribe(
{ data ->
val res = parser(data)
callback.onResult(res, params.requestedStartPosition)
initialLoad.postValue(NetworkState.LOADED)
},
{ error ->
retry = {
loadInitial(params, callback)
}
initialLoad.postValue(NetworkState.error(error.message))
}
).addTo(disposables)
}
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<T>) {
networkState.postValue(NetworkState.LOADING)
retry = null
if (source.exhausted) {
return callback.onResult(emptyList())
}
mastodonApi.searchObservable(
query = searchRequest,
type = searchType.apiParameter,
resolve = true,
limit = params.loadSize,
offset = params.startPosition,
following = false)
.subscribe(
{ data ->
// Working around Mastodon bug where exact match is returned no matter
// which offset is requested (so if we search for a full username, it's
// infinite)
// see https://github.com/tootsuite/mastodon/issues/11365
// see https://github.com/tootsuite/mastodon/issues/13083
val res = if ((data.accounts.size == 1 && data.accounts[0].username.equals(searchRequest, ignoreCase = true))
|| (data.statuses.size == 1 && data.statuses[0].url.equals(searchRequest))) {
listOf()
} else {
parser(data)
}
if (res.isEmpty()) {
source.exhausted = true
}
callback.onResult(res)
networkState.postValue(NetworkState.LOADED)
},
{ error ->
retry = {
loadRange(params, callback)
}
networkState.postValue(NetworkState.error(error.message))
}
).addTo(disposables)
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.search.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.databinding.ItemHashtagBinding
import com.keylesspalace.tusky.entity.HashTag
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
class SearchHashtagsAdapter(private val linkListener: LinkListener)
: PagedListAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
: PagingDataAdapter<HashTag, BindingHolder<ItemHashtagBinding>>(HASHTAG_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemHashtagBinding> {
val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -48,7 +48,5 @@ class SearchHashtagsAdapter(private val linkListener: LinkListener)
override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
oldItem.name == newItem.name
}
}
}
}

View file

@ -0,0 +1,83 @@
/* 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.search.adapter
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.rx3.await
class SearchPagingSource<T: Any>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val searchRequest: String,
private val initialItems: List<T>?,
private val parser: (SearchResult) -> List<T>) : PagingSource<Int, T>() {
override fun getRefreshKey(state: PagingState<Int, T>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
if (searchRequest.isEmpty()) {
return LoadResult.Page(
data = emptyList(),
prevKey = null,
nextKey = null
)
}
if (params.key == null && !initialItems.isNullOrEmpty()) {
return LoadResult.Page(
data = initialItems.toList(),
prevKey = null,
nextKey = initialItems.size
)
}
val currentKey = params.key ?: 0
try {
val data = mastodonApi.searchObservable(
query = searchRequest,
type = searchType.apiParameter,
resolve = true,
limit = params.loadSize,
offset = currentKey,
following = false
).await()
val res = parser(data)
val nextKey = if (res.isEmpty()) {
null
} else {
currentKey + res.size
}
return LoadResult.Page(
data = res,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
return LoadResult.Error(e)
}
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -15,30 +15,39 @@
package com.keylesspalace.tusky.components.search.adapter
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.concurrent.Executor
class SearchDataSourceFactory<T>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val searchRequest: String,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor,
private val cacheData: List<T>? = null,
private val parser: (SearchResult?) -> List<T>) : DataSource.Factory<Int, T>() {
class SearchPagingSourceFactory<T : Any>(
private val mastodonApi: MastodonApi,
private val searchType: SearchType,
private val initialItems: List<T>? = null,
private val parser: (SearchResult) -> List<T>
) : () -> SearchPagingSource<T> {
val sourceLiveData = MutableLiveData<SearchDataSource<T>>()
private var searchRequest: String = ""
var exhausted = false
private var currentSource: SearchPagingSource<T>? = null
override fun create(): DataSource<Int, T> {
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser, this)
sourceLiveData.postValue(source)
return source
override fun invoke(): SearchPagingSource<T> {
return SearchPagingSource(
mastodonApi = mastodonApi,
searchType = searchType,
searchRequest = searchRequest,
initialItems = initialItems,
parser = parser
).also { source ->
currentSource = source
}
}
fun newSearch(newSearchRequest: String) {
this.searchRequest = newSearchRequest
currentSource?.invalidate()
}
fun invalidate() {
currentSource?.invalidate()
}
}

View file

@ -1,56 +0,0 @@
/* Copyright 2019 Joel Pyska
*
* 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.search.adapter
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Listing
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.concurrent.Executors
class SearchRepository<T>(private val mastodonApi: MastodonApi) {
private val executor = Executors.newSingleThreadExecutor()
fun getSearchData(searchType: SearchType, searchRequest: String, disposables: CompositeDisposable, pageSize: Int = 20,
initialItems: List<T>? = null, parser: (SearchResult?) -> List<T>): Listing<T> {
val sourceFactory = SearchDataSourceFactory(mastodonApi, searchType, searchRequest, disposables, executor, initialItems, parser)
val livePagedList = sourceFactory.toLiveData(
config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2),
fetchExecutor = executor
)
return Listing(
pagedList = livePagedList,
networkState = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.networkState
},
retry = {
sourceFactory.sourceLiveData.value?.retry()
},
refresh = {
sourceFactory.sourceLiveData.value?.invalidate()
},
refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.initialLoad
}
)
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -17,9 +17,8 @@ package com.keylesspalace.tusky.components.search.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Status
@ -28,36 +27,34 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class SearchStatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, StatusViewHolder>(STATUS_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status, parent, false)
.inflate(R.layout.item_status, parent, false)
return StatusViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
getItem(position)?.let { item ->
(holder as StatusViewHolder).setupWithStatus(item.second, statusListener, statusDisplayOptions)
holder.setupWithStatus(item.second, statusListener, statusDisplayOptions)
}
}
public override fun getItem(position: Int): Pair<Status, StatusViewData.Concrete>? {
return super.getItem(position)
fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
return getItem(position)
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
oldItem.second == newItem.second
oldItem == newItem
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
oldItem.second.id == newItem.second.id
oldItem.second.id == newItem.second.id
}
}
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -15,17 +15,16 @@
package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.NetworkState
import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() {
override fun createAdapter(): PagedListAdapter<Account, *> {
override fun createAdapter(): PagingDataAdapter<Account, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
return SearchAccountsAdapter(
@ -35,12 +34,8 @@ class SearchAccountsFragment : SearchFragment<Account>() {
)
}
override val networkStateRefresh: LiveData<NetworkState>
get() = viewModel.networkStateAccountRefresh
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateAccount
override val data: LiveData<PagedList<Account>>
get() = viewModel.accounts
override val data: Flow<PagingData<Account>>
get() = viewModel.accountsFlow
companion object {
fun newInstance() = SearchAccountsFragment()

View file

@ -4,9 +4,10 @@ import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
@ -21,10 +22,14 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
abstract class SearchFragment<T: Any> : Fragment(R.layout.fragment_search),
LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener {
@Inject
@ -36,12 +41,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
private var snackbarErrorRetry: Snackbar? = null
abstract fun createAdapter(): PagedListAdapter<T, *>
abstract fun createAdapter(): PagingDataAdapter<T, *>
abstract val networkStateRefresh: LiveData<NetworkState>
abstract val networkState: LiveData<NetworkState>
abstract val data: LiveData<PagedList<T>>
protected lateinit var adapter: PagedListAdapter<T, *>
abstract val data: Flow<PagingData<T>>
protected lateinit var adapter: PagingDataAdapter<T, *>
private var currentQuery: String = ""
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initAdapter()
@ -55,32 +60,32 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
}
private fun subscribeObservables() {
data.observe(viewLifecycleOwner) {
adapter.submitList(it)
}
networkStateRefresh.observe(viewLifecycleOwner) {
binding.searchProgressBar.visible(it == NetworkState.LOADING)
if (it.status == Status.FAILED) {
showError()
}
checkNoData()
}
networkState.observe(viewLifecycleOwner) {
binding.progressBarBottom.visible(it == NetworkState.LOADING)
if (it.status == Status.FAILED) {
showError()
viewLifecycleOwner.lifecycleScope.launch {
data.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
private fun checkNoData() {
showNoData(adapter.itemCount == 0)
adapter.addLoadStateListener { loadState ->
if (loadState.refresh is LoadState.Error) {
showError()
}
val isNewSearch = currentQuery != viewModel.currentQuery
binding.searchProgressBar.visible(loadState.refresh == LoadState.Loading && isNewSearch && !binding.swipeRefreshLayout.isRefreshing)
binding.searchRecyclerView.visible(loadState.refresh is LoadState.NotLoading || !isNewSearch || binding.swipeRefreshLayout.isRefreshing)
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
currentQuery = viewModel.currentQuery
}
binding.progressBarBottom.visible(loadState.append == LoadState.Loading)
binding.searchNoResultsText.visible(loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty())
}
}
private fun initAdapter() {
@ -92,20 +97,12 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
(binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
private fun showNoData(isEmpty: Boolean) {
if (isEmpty && networkStateRefresh.value == NetworkState.LOADED) {
binding.searchNoResultsText.show()
} else {
binding.searchNoResultsText.hide()
}
}
private fun showError() {
if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) {
snackbarErrorRetry = null
viewModel.retryAllSearches()
adapter.retry()
}
snackbarErrorRetry?.show()
}
@ -123,11 +120,6 @@ abstract class SearchFragment<T> : Fragment(R.layout.fragment_search),
get() = (activity as? BottomSheetActivity)
override fun onRefresh() {
// Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins.
binding.swipeRefreshLayout.post {
binding.swipeRefreshLayout.isRefreshing = false
}
viewModel.retryAllSearches()
adapter.refresh()
}
}

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -15,22 +15,18 @@
package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.util.NetworkState
import kotlinx.coroutines.flow.Flow
class SearchHashtagsFragment : SearchFragment<HashTag>() {
override val networkStateRefresh: LiveData<NetworkState>
get() = viewModel.networkStateHashTagRefresh
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateHashTag
override val data: LiveData<PagedList<HashTag>>
get() = viewModel.hashtags
override fun createAdapter(): PagedListAdapter<HashTag, *> = SearchHashtagsAdapter(this)
override val data: Flow<PagingData<HashTag>>
get() = viewModel.hashtagsFlow
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
companion object {
fun newInstance() = SearchHashtagsFragment()

View file

@ -1,4 +1,4 @@
/* Copyright 2019 Joel Pyska
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -32,9 +32,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -57,26 +56,22 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
override val networkStateRefresh: LiveData<NetworkState>
get() = viewModel.networkStateStatusRefresh
override val networkState: LiveData<NetworkState>
get() = viewModel.networkStateStatus
override val data: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>>
get() = viewModel.statuses
override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
get() = viewModel.statusesFlow
private val searchAdapter
get() = super.adapter as SearchStatusesAdapter
override fun createAdapter(): PagedListAdapter<Pair<Status, StatusViewData.Concrete>, *> {
override fun createAdapter(): PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
@ -96,37 +91,37 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.getItem(position)?.let {
searchAdapter.item(position)?.let {
viewModel.contentHiddenChange(it, isShowing)
}
}
override fun onReply(position: Int) {
searchAdapter.getItem(position)?.first?.let { status ->
searchAdapter.item(position)?.first?.let { status ->
reply(status)
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { status ->
searchAdapter.item(position)?.let { status ->
viewModel.favorite(status, favourite)
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { status ->
searchAdapter.item(position)?.let { status ->
viewModel.bookmark(status, bookmark)
}
}
override fun onMore(view: View, position: Int) {
searchAdapter.getItem(position)?.first?.let {
searchAdapter.item(position)?.first?.let {
more(it, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
searchAdapter.getItem(position)?.first?.actionableStatus?.let { actionable ->
searchAdapter.item(position)?.first?.actionableStatus?.let { actionable ->
when (actionable.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable)
@ -146,26 +141,24 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
}
}
}
}
override fun onViewThread(position: Int) {
searchAdapter.getItem(position)?.first?.let { status ->
searchAdapter.item(position)?.first?.let { status ->
val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
}
}
override fun onOpenReblog(position: Int) {
searchAdapter.getItem(position)?.first?.let { status ->
searchAdapter.item(position)?.first?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id)
}
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.getItem(position)?.let {
searchAdapter.item(position)?.let {
viewModel.expandedChange(it, expanded)
}
}
@ -175,25 +168,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.getItem(position)?.let {
searchAdapter.item(position)?.let {
viewModel.collapsedChange(it, isCollapsed)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
searchAdapter.getItem(position)?.let {
searchAdapter.item(position)?.let {
viewModel.voteInPoll(it, choices)
}
}
private fun removeItem(position: Int) {
searchAdapter.getItem(position)?.let {
searchAdapter.item(position)?.let {
viewModel.removeItem(it)
}
}
override fun onReblog(reblog: Boolean, position: Int) {
searchAdapter.getItem(position)?.let { status ->
searchAdapter.item(position)?.let { status ->
viewModel.reblog(status, reblog)
}
}
@ -323,7 +316,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
return@setOnMenuItemClickListener true
}
R.id.status_mute_conversation -> {
searchAdapter.getItem(position)?.let { foundStatus ->
searchAdapter.item(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true)
}
return@setOnMenuItemClickListener true