Improve search results (#1327)
* Add entities and request for search APIv2 * Implement search adapter and fragment * Fix issue with snackbar * Implement search accounts fragment * Implement generic search fragment * Remove unneeded import * Implement "status" actions, fix issues * Remove SFragment dependency * Update app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt Co-Authored-By: Konrad Pozniak <connyduck@users.noreply.github.com> * Clean-up post review suggestions * Make TabLayout background colour match search bar * Corrected method call syntax * Added SwipeRefreshLayout to SearchFragment * Fixed refresh to update all three tabs
This commit is contained in:
parent
786a399bcd
commit
3b1288e99c
36 changed files with 1666 additions and 489 deletions
|
|
@ -0,0 +1,128 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* 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.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import kotlinx.android.synthetic.main.activity_search.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchActivity : BottomSheetActivity(), SearchView.OnQueryTextListener, HasAndroidInjector {
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private lateinit var viewModel: SearchViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_search)
|
||||
viewModel = ViewModelProviders.of(this, viewModelFactory)[SearchViewModel::class.java]
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
setupPages()
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun setupPages() {
|
||||
pages.adapter = SearchPagerAdapter(this, supportFragmentManager)
|
||||
tabs.setupWithViewPager(pages)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
|
||||
menuInflater.inflate(R.menu.search_toolbar, menu)
|
||||
val searchView = menu.findItem(R.id.action_search)
|
||||
.actionView as SearchView
|
||||
setupSearchView(searchView)
|
||||
|
||||
if (viewModel.currentQuery != null) {
|
||||
searchView.setQuery(viewModel.currentQuery, false)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
if (Intent.ACTION_SEARCH == intent.action) {
|
||||
viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY)
|
||||
viewModel.search(viewModel.currentQuery)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSearchView(searchView: SearchView) {
|
||||
searchView.setIconifiedByDefault(false)
|
||||
|
||||
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
|
||||
|
||||
searchView.setOnQueryTextListener(this)
|
||||
searchView.requestFocus()
|
||||
|
||||
searchView.maxWidth = Integer.MAX_VALUE
|
||||
}
|
||||
|
||||
override fun androidInjector(): AndroidInjector<Any>? {
|
||||
return androidInjector
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getIntent(context: Context) = Intent(context, SearchActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.keylesspalace.tusky.components.search
|
||||
|
||||
enum class SearchType(val apiParameter: String) {
|
||||
Status("statuses"),
|
||||
Account("accounts"),
|
||||
Hashtag("hashtags")
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
package com.keylesspalace.tusky.components.search
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.PagedList
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchRepository
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
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.Listing
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.ViewDataUtils
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class SearchViewModel @Inject constructor(
|
||||
mastodonApi: MastodonApi,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val accountManager: AccountManager) : ViewModel() {
|
||||
|
||||
var currentQuery: String? = null
|
||||
|
||||
var activeAccount: AccountEntity?
|
||||
get() = accountManager.activeAccount
|
||||
set(value) {
|
||||
accountManager.activeAccount = value
|
||||
}
|
||||
|
||||
val mediaPreviewEnabled: Boolean
|
||||
get() = activeAccount?.mediaPreviewEnabled ?: false
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val statusesRepository = SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
||||
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
||||
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
|
||||
var alwaysShowSensitiveMedia: Boolean = activeAccount?.alwaysShowSensitiveMedia
|
||||
?: false
|
||||
|
||||
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
|
||||
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> = Transformations.switchMap(repoResultStatus) { it.pagedList }
|
||||
val networkStateStatus: LiveData<NetworkState> = Transformations.switchMap(repoResultStatus) { it.networkState }
|
||||
val networkStateStatusRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResultStatus) { it.refreshState }
|
||||
|
||||
private val repoResultAccount = MutableLiveData<Listing<Account>>()
|
||||
val accounts: LiveData<PagedList<Account>> = Transformations.switchMap(repoResultAccount) { it.pagedList }
|
||||
val networkStateAccount: LiveData<NetworkState> = Transformations.switchMap(repoResultAccount) { it.networkState }
|
||||
val networkStateAccountRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResultAccount) { it.refreshState }
|
||||
|
||||
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
|
||||
val hashtags: LiveData<PagedList<HashTag>> = Transformations.switchMap(repoResultHashTag) { it.pagedList }
|
||||
val networkStateHashTag: LiveData<NetworkState> = Transformations.switchMap(repoResultHashTag) { it.networkState }
|
||||
val networkStateHashTagRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResultHashTag) { it.refreshState }
|
||||
|
||||
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, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia)!!) }
|
||||
?: emptyList())
|
||||
.apply {
|
||||
loadedStatuses.addAll(this)
|
||||
}
|
||||
}
|
||||
repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) {
|
||||
it?.accounts ?: emptyList()
|
||||
}
|
||||
repoResultHashTag.value = hashtagsRepository.getSearchData(SearchType.Hashtag, String.format(Locale.getDefault(),"#%s",query), disposables) {
|
||||
it?.hashtags ?: emptyList()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
||||
timelineCases.delete(status.first.id)
|
||||
if (loadedStatuses.remove(status))
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
|
||||
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData())
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||
disposables.add(timelineCases.reblog(status.first, reblog)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ setRebloggedForStatus(status, reblog) },
|
||||
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||
status.first.reblogged = reblog
|
||||
status.first.reblog?.reblogged = reblog
|
||||
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData())
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData())
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData())
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
|
||||
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
|
||||
updateStatus(status, votedPoll)
|
||||
disposables.add(timelineCases.voteInPoll(status.first, choices)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ newPoll -> updateStatus(status, newPoll) },
|
||||
{ t ->
|
||||
Log.d(TAG,
|
||||
"Failed to vote in poll: ${status.first.id}", t)
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
|
||||
val newViewData = StatusViewData.Builder(status.second)
|
||||
.setPoll(newPoll)
|
||||
.createStatusViewData()
|
||||
loadedStatuses[idx] = Pair(status.first, newViewData)
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
||||
val idx = loadedStatuses.indexOf(status)
|
||||
if (idx >= 0) {
|
||||
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData())
|
||||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
disposables.add(timelineCases.favourite(status.first, isFavorited)
|
||||
.onErrorReturnItem(status.first)
|
||||
.subscribe())
|
||||
}
|
||||
|
||||
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
|
||||
return accountManager.getAllAccountsOrderedByActive()
|
||||
}
|
||||
|
||||
fun muteAcount(accountId: String) {
|
||||
timelineCases.mute(accountId)
|
||||
}
|
||||
|
||||
fun pinAccount(status: Status, isPin: Boolean) {
|
||||
timelineCases.pin(status, isPin)
|
||||
}
|
||||
|
||||
fun blockAccount(accountId: String) {
|
||||
timelineCases.block(accountId)
|
||||
}
|
||||
|
||||
fun deleteStatus(id: String) {
|
||||
timelineCases.delete(id)
|
||||
}
|
||||
|
||||
fun retryAllSearches() {
|
||||
search(currentQuery)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SearchViewModel"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/* 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 android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
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.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
||||
class SearchAccountsAdapter(private val linkListener: LinkListener)
|
||||
: PagedListAdapter<Account, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_account, parent, false)
|
||||
return AccountViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
(holder as? AccountViewHolder)?.apply {
|
||||
setupWithAccount(item)
|
||||
setupLinkListener(linkListener)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public override fun getItem(position: Int): Account? {
|
||||
return super.getItem(position)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Account>() {
|
||||
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean =
|
||||
oldItem.deepEquals(newItem)
|
||||
|
||||
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
|
||||
oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/* 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 android.annotation.SuppressLint
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.PositionalDataSource
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.SearchResults2
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
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: (SearchResults2?) -> List<T>) : PositionalDataSource<T>() {
|
||||
|
||||
val networkState = MutableLiveData<NetworkState>()
|
||||
|
||||
private var retry: (() -> Any)? = null
|
||||
|
||||
val initialLoad = MutableLiveData<NetworkState>()
|
||||
|
||||
fun retry() {
|
||||
retry?.let {
|
||||
retryExecutor.execute {
|
||||
it.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<T>) {
|
||||
if (!initialItems.isNullOrEmpty()) {
|
||||
callback.onResult(initialItems, 0)
|
||||
} else {
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
retry = null
|
||||
initialLoad.postValue(NetworkState.LOADING)
|
||||
mastodonApi.searchObservable(searchType.apiParameter, searchRequest, true, params.requestedLoadSize, 0, false)
|
||||
.doOnSubscribe {
|
||||
disposables.add(it)
|
||||
}
|
||||
.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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<T>) {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
retry = null
|
||||
mastodonApi.searchObservable(searchType.apiParameter, searchRequest, true, params.loadSize, params.startPosition, false)
|
||||
.doOnSubscribe {
|
||||
disposables.add(it)
|
||||
}
|
||||
.subscribe(
|
||||
{ data ->
|
||||
val res = parser(data)
|
||||
callback.onResult(res)
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
|
||||
},
|
||||
{ error ->
|
||||
retry = {
|
||||
loadRange(params, callback)
|
||||
}
|
||||
networkState.postValue(NetworkState.error(error.message))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/* 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.DataSource
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.SearchResults2
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.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: (SearchResults2?) -> List<T>) : DataSource.Factory<Int, T>() {
|
||||
val sourceLiveData = MutableLiveData<SearchDataSource<T>>()
|
||||
override fun create(): DataSource<Int, T> {
|
||||
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser)
|
||||
sourceLiveData.postValue(source)
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/* 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 android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.HashtagViewHolder
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
||||
class SearchHashtagsAdapter(private val linkListener: LinkListener)
|
||||
: PagedListAdapter<HashTag, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_hashtag, parent, false)
|
||||
return HashtagViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
(holder as? HashtagViewHolder)?.apply {
|
||||
setup(item.name, linkListener)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<HashTag>() {
|
||||
override fun areContentsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
|
||||
oldItem.name == newItem.name
|
||||
|
||||
override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
|
||||
oldItem.name == newItem.name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/* 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 android.content.Context
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment
|
||||
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
|
||||
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
|
||||
|
||||
class SearchPagerAdapter(private val context: Context, manager: FragmentManager) : FragmentPagerAdapter(manager) {
|
||||
override fun getItem(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> SearchStatusesFragment.newInstance()
|
||||
1 -> SearchAccountsFragment.newInstance()
|
||||
2 -> SearchHashtagsFragment.newInstance()
|
||||
else -> throw IllegalArgumentException("Unknown page index: $position")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPageTitle(position: Int): CharSequence? {
|
||||
return when (position) {
|
||||
0 -> context.getString(R.string.title_statuses)
|
||||
1 -> context.getString(R.string.title_accounts)
|
||||
2 -> context.getString(R.string.title_hashtags_dialog)
|
||||
else -> throw IllegalArgumentException("Unknown page index: $position")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCount(): Int = 3
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/* 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.SearchResults2
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Listing
|
||||
import io.reactivex.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: (SearchResults2?) -> 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
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/* 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 android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
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
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
||||
class SearchStatusesAdapter(private val useAbsoluteTime: Boolean,
|
||||
private val mediaPreviewEnabled: Boolean,
|
||||
private val showBotOverlay: Boolean,
|
||||
private val animateAvatar: Boolean,
|
||||
private val statusListener: StatusActionListener)
|
||||
: PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_status, parent, false)
|
||||
return StatusViewHolder(view, useAbsoluteTime)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
(holder as? StatusViewHolder)?.setupWithStatus(item.second, statusListener,
|
||||
mediaPreviewEnabled, showBotOverlay, animateAvatar)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public override fun getItem(position: Int): Pair<Status, StatusViewData.Concrete>? {
|
||||
return super.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.deepEquals(newItem.second)
|
||||
|
||||
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||
oldItem.second.id == newItem.second.id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/* 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.fragments
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
|
||||
class SearchAccountsFragment : SearchFragment<Account>() {
|
||||
override fun createAdapter(): PagedListAdapter<Account, *> = SearchAccountsAdapter(this)
|
||||
|
||||
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
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SearchAccountsFragment()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.keylesspalace.tusky.components.search.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.AccountActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewTagActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import kotlinx.android.synthetic.main.fragment_search.*
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
abstract class SearchFragment<T> : Fragment(),
|
||||
LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener {
|
||||
private var isSwipeToRefreshEnabled: Boolean = true
|
||||
private var snackbarErrorRetry: Snackbar? = null
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
protected lateinit var viewModel: SearchViewModel
|
||||
|
||||
abstract fun createAdapter(): PagedListAdapter<T, *>
|
||||
|
||||
abstract val networkStateRefresh: LiveData<NetworkState>
|
||||
abstract val networkState: LiveData<NetworkState>
|
||||
abstract val data: LiveData<PagedList<T>>
|
||||
protected lateinit var adapter: PagedListAdapter<T, *>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[SearchViewModel::class.java]
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_search, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
initAdapter()
|
||||
setupSwipeRefreshLayout()
|
||||
subscribeObservables()
|
||||
}
|
||||
|
||||
private fun setupSwipeRefreshLayout() {
|
||||
swipeRefreshLayout.setOnRefreshListener(this)
|
||||
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(
|
||||
ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground))
|
||||
|
||||
}
|
||||
|
||||
private fun subscribeObservables() {
|
||||
data.observe(viewLifecycleOwner, Observer {
|
||||
adapter.submitList(it)
|
||||
})
|
||||
|
||||
networkStateRefresh.observe(viewLifecycleOwner, Observer {
|
||||
|
||||
searchProgressBar.visible(it == NetworkState.LOADING)
|
||||
|
||||
if (it.status == Status.FAILED)
|
||||
showError(it.msg)
|
||||
checkNoData()
|
||||
|
||||
})
|
||||
|
||||
networkState.observe(viewLifecycleOwner, Observer {
|
||||
|
||||
progressBarBottom.visible(it == NetworkState.LOADING)
|
||||
|
||||
if (it.status == Status.FAILED)
|
||||
showError(it.msg)
|
||||
})
|
||||
}
|
||||
|
||||
private fun checkNoData() {
|
||||
showNoData(adapter.itemCount == 0)
|
||||
}
|
||||
|
||||
private fun initAdapter() {
|
||||
searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||
searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context)
|
||||
adapter = createAdapter()
|
||||
searchRecyclerView.adapter = adapter
|
||||
|
||||
}
|
||||
|
||||
private fun showNoData(isEmpty: Boolean) {
|
||||
if (isEmpty && networkStateRefresh.value == NetworkState.LOADED)
|
||||
searchNoResultsText.show()
|
||||
else
|
||||
searchNoResultsText.hide()
|
||||
}
|
||||
|
||||
private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) {
|
||||
if (snackbarErrorRetry?.isShown != true) {
|
||||
snackbarErrorRetry = Snackbar.make(layoutRoot, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
|
||||
snackbarErrorRetry?.setAction(R.string.action_retry) {
|
||||
snackbarErrorRetry = null
|
||||
viewModel.retryAllSearches()
|
||||
}
|
||||
snackbarErrorRetry?.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
|
||||
|
||||
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
bottomSheetActivity?.viewUrl(url)
|
||||
}
|
||||
|
||||
protected val bottomSheetActivity = (activity as? BottomSheetActivity)
|
||||
|
||||
override fun onRefresh() {
|
||||
|
||||
// Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins.
|
||||
Timer("DelayDismiss", false).schedule(200) {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.retryAllSearches()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/* 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.fragments
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
|
||||
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)
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SearchHashtagsFragment()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
/* 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.fragments
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.preference.PreferenceManager
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.URLSpan
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagedList
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.*
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.android.synthetic.main.fragment_search.*
|
||||
import java.util.*
|
||||
|
||||
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 fun createAdapter(): PagedListAdapter<Pair<Status, StatusViewData.Concrete>, *> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context)
|
||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
|
||||
val showBotOverlay = preferences.getBoolean("showBotOverlay", true)
|
||||
val animateAvatar = preferences.getBoolean("animateGifAvatars", false)
|
||||
|
||||
searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||
searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context)
|
||||
return SearchStatusesAdapter(useAbsoluteTime, viewModel.mediaPreviewEnabled, showBotOverlay, animateAvatar, this)
|
||||
}
|
||||
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||
viewModel.contentHiddenChange(it, isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status ->
|
||||
reply(status)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let { status ->
|
||||
viewModel.favorite(status, favourite)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let {
|
||||
more(it, view, position)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.actionableStatus?.let { actionable ->
|
||||
when (actionable.attachments[attachmentIndex].type) {
|
||||
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE -> {
|
||||
val attachments = AttachmentViewData.list(actionable)
|
||||
val intent = ViewMediaActivity.newIntent(context, attachments,
|
||||
attachmentIndex)
|
||||
if (view != null) {
|
||||
val url = actionable.attachments[attachmentIndex].url
|
||||
ViewCompat.setTransitionName(view, url)
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(),
|
||||
view, url)
|
||||
startActivity(intent, options.toBundle())
|
||||
} else {
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
Attachment.Type.UNKNOWN -> {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status ->
|
||||
val actionableStatus = status.actionableStatus
|
||||
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status ->
|
||||
bottomSheetActivity?.viewAccount(status.account.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||
viewModel.expandedChange(it, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadMore(position: Int) {
|
||||
//Ignore
|
||||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||
viewModel.collapsedChange(it, isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||
viewModel.voteInPoll(it, choices)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeItem(position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||
viewModel.removeItem(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let { status ->
|
||||
viewModel.reblog(status, reblog)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SearchStatusesFragment()
|
||||
}
|
||||
|
||||
private fun reply(status: Status) {
|
||||
val inReplyToId = status.actionableId
|
||||
val actionableStatus = status.actionableStatus
|
||||
val replyVisibility = actionableStatus.visibility
|
||||
val contentWarning = actionableStatus.spoilerText
|
||||
val mentions = actionableStatus.mentions
|
||||
val mentionedUsernames = LinkedHashSet<String>()
|
||||
mentionedUsernames.add(actionableStatus.account.username)
|
||||
val loggedInUsername = viewModel.activeAccount?.username
|
||||
for ((_, _, username) in mentions) {
|
||||
mentionedUsernames.add(username)
|
||||
}
|
||||
mentionedUsernames.remove(loggedInUsername)
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(inReplyToId)
|
||||
.replyVisibility(replyVisibility)
|
||||
.contentWarning(contentWarning)
|
||||
.mentionedUsernames(mentionedUsernames)
|
||||
.replyingStatusAuthor(actionableStatus.account.localUsername)
|
||||
.replyingStatusContent(actionableStatus.content.toString())
|
||||
.build(context)
|
||||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
private fun more(status: Status, view: View, position: Int) {
|
||||
val id = status.actionableId
|
||||
val accountId = status.actionableStatus.account.id
|
||||
val accountUsername = status.actionableStatus.account.username
|
||||
val content = status.actionableStatus.content
|
||||
val statusUrl = status.actionableStatus.url
|
||||
val accounts = viewModel.getAllAccountsOrderedByActive()
|
||||
var openAsTitle: String? = null
|
||||
|
||||
val loggedInAccountId = viewModel.activeAccount?.accountId
|
||||
|
||||
val popup = PopupMenu(view.context, view)
|
||||
// Give a different menu depending on whether this is the user's own toot or not.
|
||||
if (loggedInAccountId == null || loggedInAccountId != accountId) {
|
||||
popup.inflate(R.menu.status_more)
|
||||
val menu = popup.menu
|
||||
menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
|
||||
} else {
|
||||
popup.inflate(R.menu.status_more_for_user)
|
||||
val menu = popup.menu
|
||||
menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank()
|
||||
when (status.visibility) {
|
||||
Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> {
|
||||
val textId = getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action)
|
||||
menu.add(0, R.id.pin, 1, textId)
|
||||
}
|
||||
Status.Visibility.PRIVATE -> {
|
||||
var reblogged = status.reblogged
|
||||
if (status.reblog != null) reblogged = status.reblog.reblogged
|
||||
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
|
||||
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
|
||||
}
|
||||
Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> {
|
||||
} //Ignore
|
||||
}
|
||||
}
|
||||
|
||||
val menu = popup.menu
|
||||
val openAsItem = menu.findItem(R.id.status_open_as)
|
||||
when (accounts.size) {
|
||||
0, 1 -> openAsItem.isVisible = false
|
||||
2 -> for (account in accounts) {
|
||||
if (account !== viewModel.activeAccount) {
|
||||
openAsTitle = String.format(getString(R.string.action_open_as), account.fullName)
|
||||
break
|
||||
}
|
||||
}
|
||||
else -> openAsTitle = String.format(getString(R.string.action_open_as), "…")
|
||||
}
|
||||
openAsItem.title = openAsTitle
|
||||
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.status_share_content -> {
|
||||
var statusToShare: Status? = status
|
||||
if (statusToShare!!.reblog != null) statusToShare = statusToShare.reblog
|
||||
|
||||
val sendIntent = Intent()
|
||||
sendIntent.action = Intent.ACTION_SEND
|
||||
|
||||
val stringToShare = statusToShare!!.account.username +
|
||||
" - " +
|
||||
statusToShare.content.toString()
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_content_to)))
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_share_link -> {
|
||||
val sendIntent = Intent()
|
||||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_link_to)))
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_copy_link -> {
|
||||
val clipboard = activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(null, statusUrl)
|
||||
clipboard.primaryClip = clip
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_open_as -> {
|
||||
showOpenAsDialog(statusUrl!!, item.title)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_download_media -> {
|
||||
requestDownloadAllMedia(status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_mute -> {
|
||||
viewModel.muteAcount(accountId)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_block -> {
|
||||
viewModel.blockAccount(accountId)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_report -> {
|
||||
openReportPage(accountId, accountUsername, id, content)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_unreblog_private -> {
|
||||
onReblog(false, position)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_reblog_private -> {
|
||||
onReblog(true, position)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_delete -> {
|
||||
showConfirmDeleteDialog(id, position)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_delete_and_redraft -> {
|
||||
showConfirmEditDialog(id, position, status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.pin -> {
|
||||
viewModel.pinAccount(status, !status.isPinned())
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) {
|
||||
bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener {
|
||||
override fun onAccountSelected(account: AccountEntity) {
|
||||
openAsAccount(statusUrl, account)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun openAsAccount(statusUrl: String, account: AccountEntity) {
|
||||
viewModel.activeAccount = account
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
intent.putExtra(MainActivity.STATUS_URL, statusUrl)
|
||||
startActivity(intent)
|
||||
(activity as BaseActivity).finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
private fun downloadAllMedia(status: Status) {
|
||||
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
|
||||
for ((_, url) in status.attachments) {
|
||||
val uri = Uri.parse(url)
|
||||
val filename = uri.lastPathSegment
|
||||
|
||||
val downloadManager = activity!!.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val request = DownloadManager.Request(uri)
|
||||
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
|
||||
downloadManager.enqueue(request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestDownloadAllMedia(status: Status) {
|
||||
val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
(activity as BaseActivity).requestPermissions(permissions) { _, grantResults ->
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
downloadAllMedia(status)
|
||||
} else {
|
||||
Toast.makeText(context, R.string.error_media_download_permission, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openReportPage(accountId: String, accountUsername: String, statusId: String,
|
||||
statusContent: Spanned) {
|
||||
startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId, statusContent))
|
||||
}
|
||||
|
||||
private fun showConfirmDeleteDialog(id: String, position: Int) {
|
||||
context?.let {
|
||||
AlertDialog.Builder(it)
|
||||
.setMessage(R.string.dialog_delete_toot_warning)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.deleteStatus(id)
|
||||
removeItem(position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
|
||||
activity?.let {
|
||||
AlertDialog.Builder(it)
|
||||
.setMessage(R.string.dialog_redraft_toot_warning)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.deleteStatus(id)
|
||||
removeItem(position)
|
||||
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.tootText(getEditableText(status.content, status.mentions))
|
||||
.inReplyToId(status.inReplyToId)
|
||||
.visibility(status.visibility)
|
||||
.contentWarning(status.spoilerText)
|
||||
.mediaAttachments(status.attachments)
|
||||
.sensitive(status.sensitive)
|
||||
.build(context)
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEditableText(content: Spanned, mentions: Array<Status.Mention>): String {
|
||||
val builder = SpannableStringBuilder(content)
|
||||
for (span in content.getSpans(0, content.length, URLSpan::class.java)) {
|
||||
val url = span.url
|
||||
for ((_, url1, username) in mentions) {
|
||||
if (url == url1) {
|
||||
val start = builder.getSpanStart(span)
|
||||
val end = builder.getSpanEnd(span)
|
||||
builder.replace(start, end, "@$username")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue