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:
pandasoft0 2019-07-19 21:10:20 +03:00 committed by Konrad Pozniak
commit 3b1288e99c
36 changed files with 1666 additions and 489 deletions

View file

@ -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)
}
}

View file

@ -0,0 +1,7 @@
package com.keylesspalace.tusky.components.search
enum class SearchType(val apiParameter: String) {
Status("statuses"),
Account("accounts"),
Hashtag("hashtags")
}

View file

@ -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"
}
}

View file

@ -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
}
}
}

View file

@ -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))
}
)
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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
}
)
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}