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
parent 786a399bcd
commit 3b1288e99c
36 changed files with 1666 additions and 489 deletions

View File

@ -7,33 +7,34 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" /> <!-- For notifications -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="22"/> <!-- for day/night mode -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- For sending toots with foreground service -->
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="22" /> <!-- for day/night mode -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name=".TuskyApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".TuskyApplication"
android:supportsRtl="true"
android:theme="@style/TuskyTheme">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts"/>
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".SavedTootActivity"
android:configChanges="orientation|screenSize|keyboardHidden">
</activity>
android:configChanges="orientation|screenSize|keyboardHidden"/>
<activity
android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize">
@ -53,98 +54,110 @@
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="com.keylesspalace.tusky.service.AccountChooserService"
/>
android:value="com.keylesspalace.tusky.service.AccountChooserService" />
</activity>
<activity
android:name=".ComposeActivity"
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize">
</activity>
android:windowSoftInputMode="stateVisible|adjustResize"/>
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme"
android:configChanges="orientation|screenSize|keyboardHidden"/>
<activity android:name=".AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden"/>
<activity
android:name=".ViewMediaActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/TuskyBaseTheme" />
<activity
android:name=".AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity android:name=".EditProfileActivity" />
<activity android:name=".PreferencesActivity" />
<activity android:name=".FavouritesActivity" />
<activity android:name=".AccountListActivity" />
<activity android:name=".AboutActivity" />
<activity android:name=".TabPreferenceActivity" />
<activity
android:name="com.theartofdev.edmodo.cropper.CropImageActivity"
android:theme="@style/Base.Theme.AppCompat" />
<activity
android:name=".SearchActivity"
android:name=".components.search.SearchActivity"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable" />
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<activity android:name=".ListsActivity" />
<activity android:name=".ModalTimelineActivity" />
<activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" />
<activity android:name=".components.report.ReportActivity"
<activity
android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"
android:exported="false" />
<service
tools:targetApi="24"
android:name="com.keylesspalace.tusky.service.TuskyTileService"
android:name=".service.TuskyTileService"
android:icon="@drawable/ic_tusky"
android:label="Compose Toot"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:targetApi="24">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service android:name=".service.SendTootService" />
<service
tools:targetApi="23"
android:name="com.keylesspalace.tusky.service.AccountChooserService"
android:name=".service.AccountChooserService"
android:label="@string/app_name"
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE"
>
tools:targetApi="23">
<intent-filter>
<action android:name="android.service.chooser.ChooserTargetService" />
</intent-filter>
@ -161,4 +174,4 @@
</provider>
</application>
</manifest>
</manifest>

View File

@ -47,6 +47,7 @@ import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
import com.keylesspalace.tusky.components.conversation.ConversationsRepository;
import com.keylesspalace.tusky.components.search.SearchActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.fragment.SFragment;
@ -286,7 +287,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
return true;
}
case KeyEvent.KEYCODE_SEARCH: {
startActivityWithSlideInAnimation(new Intent(this, SearchActivity.class));
startActivityWithSlideInAnimation(SearchActivity.getIntent(this));
return true;
}
}
@ -413,8 +414,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
Intent intent = new Intent(MainActivity.this, FavouritesActivity.class);
startActivityWithSlideInAnimation(intent);
} else if (drawerItemIdentifier == DRAWER_ITEM_SEARCH) {
Intent intent = new Intent(MainActivity.this, SearchActivity.class);
startActivityWithSlideInAnimation(intent);
startActivityWithSlideInAnimation(SearchActivity.getIntent(this));
} else if (drawerItemIdentifier == DRAWER_ITEM_ACCOUNT_SETTINGS) {
Intent intent = PreferencesActivity.newIntent(MainActivity.this, PreferencesActivity.ACCOUNT_PREFERENCES);
startActivityWithSlideInAnimation(intent);

View File

@ -1,143 +0,0 @@
/* 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;
import android.app.SearchManager;
import android.app.SearchableInfo;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.FragmentTransaction;
import com.keylesspalace.tusky.fragment.SearchFragment;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasAndroidInjector;
public class SearchActivity extends BottomSheetActivity implements SearchView.OnQueryTextListener,
HasAndroidInjector {
@Inject
public DispatchingAndroidInjector<Object> androidInjector;
private String currentQuery;
private SearchFragment searchFragment;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_search);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
bar.setDisplayShowTitleEnabled(false);
}
searchFragment = new SearchFragment();
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
fragmentTransaction.replace(R.id.fragment_container, searchFragment);
fragmentTransaction.commit();
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
getMenuInflater().inflate(R.menu.search_toolbar, menu);
SearchView searchView = (SearchView) menu.findItem(R.id.action_search)
.getActionView();
setupSearchView(searchView);
if (currentQuery != null) {
searchView.setQuery(currentQuery, false);
}
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
@Override
public boolean onQueryTextChange(String newText) {
return false;
}
@Override
public boolean onQueryTextSubmit(String query) {
return false;
}
private void handleIntent(Intent intent) {
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
currentQuery = intent.getStringExtra(SearchManager.QUERY);
searchFragment.search(currentQuery);
}
}
private void setupSearchView(SearchView searchView) {
searchView.setIconifiedByDefault(false);
SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
if (searchManager != null) {
SearchableInfo searchableInfo = searchManager.getSearchableInfo(getComponentName());
searchView.setSearchableInfo(searchableInfo);
}
searchView.setOnQueryTextListener(this);
searchView.requestFocus();
searchView.setMaxWidth(Integer.MAX_VALUE);
}
@Override
public AndroidInjector<Object> androidInjector() {
return androidInjector;
}
}

View File

@ -15,7 +15,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
class AccountViewHolder extends RecyclerView.ViewHolder {
public class AccountViewHolder extends RecyclerView.ViewHolder {
private TextView username;
private TextView displayName;
private ImageView avatar;
@ -24,7 +24,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
private boolean showBotOverlay;
private boolean animateAvatar;
AccountViewHolder(View itemView) {
public AccountViewHolder(View itemView) {
super(itemView);
username = itemView.findViewById(R.id.account_username);
displayName = itemView.findViewById(R.id.account_display_name);
@ -35,7 +35,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false);
}
void setupWithAccount(Account account) {
public void setupWithAccount(Account account) {
accountId = account.getId();
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());
@ -58,7 +58,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
}
void setupLinkListener(final LinkListener listener) {
public void setupLinkListener(final LinkListener listener) {
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
}
}

View File

@ -0,0 +1,16 @@
package com.keylesspalace.tusky.adapter
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.interfaces.LinkListener
class HashtagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val hashtag: TextView = itemView.findViewById(R.id.hashtag)
fun setup(tag: String, listener: LinkListener) {
hashtag.text = String.format("#%s", tag)
hashtag.setOnClickListener { listener.onViewTag(tag) }
}
}

View File

@ -607,7 +607,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.setOnClickListener(viewThreadListener);
}
protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar) {
this.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, null);
}

View File

@ -38,7 +38,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
private TextView statusInfo;
private ToggleButton contentCollapseButton;
StatusViewHolder(View itemView, boolean useAbsoluteTime) {
public StatusViewHolder(View itemView, boolean useAbsoluteTime) {
super(itemView, useAbsoluteTime);
statusInfo = itemView.findViewById(R.id.status_info);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);

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

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector

View File

@ -25,6 +25,9 @@ import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragme
import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment
import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment
import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment
import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector
@ -50,7 +53,7 @@ abstract class FragmentBuildersModule {
abstract fun notificationsFragment(): NotificationsFragment
@ContributesAndroidInjector
abstract fun searchFragment(): SearchFragment
abstract fun searchFragment(): SearchStatusesFragment
@ContributesAndroidInjector
abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment
@ -75,4 +78,11 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun instanceListFragment(): InstanceListFragment
@ContributesAndroidInjector
abstract fun searchAccountFragment(): SearchAccountsFragment
@ContributesAndroidInjector
abstract fun searchHashtagsFragment(): SearchHashtagsFragment
}

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.viewmodel.*
import com.keylesspalace.tusky.viewmodel.ListsViewModel
import dagger.Binds
@ -65,5 +66,10 @@ abstract class ViewModelModule {
@ViewModelKey(ReportViewModel::class)
internal abstract fun reportViewModel(viewModel: ReportViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(SearchViewModel::class)
internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel
//Add more ViewModels here
}

View File

@ -0,0 +1,3 @@
package com.keylesspalace.tusky.entity
data class HashTag(val name: String)

View File

@ -0,0 +1,14 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
data class History(
@field:SerializedName("day")
val day: String,
@field:SerializedName("uses")
val uses: Int,
@field:SerializedName("accounts")
val accounts: Int
)

View File

@ -0,0 +1,22 @@
/* 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.entity
data class SearchResults2 (
val accounts: List<Account>,
val statuses: List<Status>,
val hashtags: List<HashTag>
)

View File

@ -1,275 +0,0 @@
/* Copyright 2018 Conny Duck
*
* 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.fragment
import android.content.Intent
import android.os.Bundle
import android.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.adapter.SearchResultsAdapter
import com.keylesspalace.tusky.entity.SearchResults
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.ViewDataUtils
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDisposable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_search.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class SearchFragment : SFragment(), StatusActionListener {
private lateinit var searchAdapter: SearchResultsAdapter
private var alwaysShowSensitiveMedia = false
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?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
val showBotOverlay = preferences.getBoolean("showBotOverlay", true)
val animateAvatar = preferences.getBoolean("animateGifAvatars", false)
val account = accountManager.activeAccount
alwaysShowSensitiveMedia = account?.alwaysShowSensitiveMedia ?: false
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true
searchRecyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
searchRecyclerView.layoutManager = LinearLayoutManager(view.context)
searchAdapter = SearchResultsAdapter(
this,
this,
mediaPreviewEnabled,
alwaysShowSensitiveMedia,
useAbsoluteTime,
showBotOverlay,
animateAvatar
)
searchRecyclerView.adapter = searchAdapter
}
fun search(query: String) {
clearResults()
val callback = object : Callback<SearchResults> {
override fun onResponse(call: Call<SearchResults>, response: Response<SearchResults>) {
if (response.isSuccessful) {
val results = response.body()
if (results != null && (results.accounts.isNotEmpty() || results.statuses.isNotEmpty() || results.hashtags.isNotEmpty())) {
searchAdapter.updateSearchResults(results)
hideFeedback()
} else {
displayNoResults()
}
} else {
onSearchFailure()
}
}
override fun onFailure(call: Call<SearchResults>, t: Throwable) {
onSearchFailure()
}
}
mastodonApi.search(query, true)
.enqueue(callback)
}
private fun onSearchFailure() {
displayNoResults()
Log.e(TAG, "Search request failed.")
}
private fun clearResults() {
searchAdapter.updateSearchResults(null)
searchProgressBar.visibility = View.VISIBLE
searchNoResultsText.visibility = View.GONE
}
private fun displayNoResults() {
if (isAdded) {
searchProgressBar.visibility = View.GONE
searchNoResultsText.visibility = View.VISIBLE
}
}
private fun hideFeedback() {
if (isAdded) {
searchProgressBar.visibility = View.GONE
searchNoResultsText.visibility = View.GONE
}
}
override fun removeItem(position: Int) {
searchAdapter.removeStatusAtPosition(position)
}
override fun onReply(position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
super.reply(status)
}
}
override fun onReblog(reblog: Boolean, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
timelineCases.reblog(status, reblog)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({
status.reblogged = reblog
searchAdapter.updateStatusAtPosition(
ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
),
position,
false
)
}, { t -> Log.d(TAG, "Failed to reblog status " + status.id, t) })
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
timelineCases.favourite(status, favourite)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({
status.favourited = favourite
searchAdapter.updateStatusAtPosition(
ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
),
position,
false
)
}, { t -> Log.d(TAG, "Failed to favourite status " + status.id, t) })
}
}
override fun onMore(view: View, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
more(status, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = searchAdapter.getStatusAtPosition(position) ?: return
viewMedia(attachmentIndex, status, view)
}
override fun onViewThread(position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
viewThread(status)
}
}
override fun onOpenReblog(position: Int) {
// there are no reblogs in search results
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
val status = searchAdapter.getConcreteStatusAtPosition(position)
if (status != null) {
val newStatus = StatusViewData.Builder(status)
.setIsExpanded(expanded).createStatusViewData()
searchAdapter.updateStatusAtPosition(newStatus, position, false)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
val status = searchAdapter.getConcreteStatusAtPosition(position)
if (status != null) {
val newStatus = StatusViewData.Builder(status)
.setIsShowingSensitiveContent(isShowing).createStatusViewData()
searchAdapter.updateStatusAtPosition(newStatus, position, true)
}
}
override fun onLoadMore(position: Int) {
// not needed here, search is not paginated
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
// TODO: No out-of-bounds check in getConcreteStatusAtPosition
val status = searchAdapter.getConcreteStatusAtPosition(position)
if (status == null) {
Log.e(TAG, String.format("Tried to access status but got null at position: %d", position))
return
}
val updatedStatus = StatusViewData.Builder(status)
.setCollapsed(isCollapsed)
.createStatusViewData()
searchAdapter.updateStatusAtPosition(updatedStatus, position, false)
searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) }
}
override fun onViewAccount(id: String) {
val intent = AccountActivity.getIntent(requireContext(), id)
startActivity(intent)
}
override fun onViewTag(tag: String) {
val intent = Intent(context, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
startActivity(intent)
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
timelineCases.voteInPoll(status, choices)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({poll ->
val viewData = ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia
)
val newViewData = StatusViewData.Builder(viewData)
.setPoll(poll)
.createStatusViewData()
searchAdapter.updateStatusAtPosition(newViewData, position, true)
}, { t -> Log.d(TAG, "Failed to vote in poll " + status.id, t) })
}
}
companion object {
const val TAG = "SearchFragment"
}
}

View File

@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.SearchResults2;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
@ -437,4 +438,7 @@ public interface MastodonApi {
@GET("api/v1/statuses/{id}")
Single<Status> statusObservable(@Path("id") String statusId);
@GET("api/v2/search")
Single<SearchResults2> searchObservable(@Query("type") String type, @Query("q") String q, @Query("resolve") Boolean resolve, @Query("limit") Integer limit, @Query("offset") Integer offset, @Query("following") Boolean following);
}

View File

@ -0,0 +1,35 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.util
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
/**
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
*/
data class MultiListing(
val pagedLists: List<LiveData<PagedList<Any>>>,
// represents the network request status to show to the user
val networkState: LiveData<NetworkState>,
// represents the refresh status to show to the user. Separate from networkState, this
// value is importantly only when refresh is requested.
val refreshState: LiveData<NetworkState>,
// refreshes the whole data and fetches it from scratch.
val refresh: () -> Unit,
// retries any failed requests.
val retry: () -> Unit)

View File

@ -19,5 +19,12 @@ data class AttachmentViewData(
AttachmentViewData(it, actionable.id, actionable.url!!)
}
}
fun list(attachments: List<Attachment>): List<AttachmentViewData> {
return attachments.map {
AttachmentViewData(it, it.id, it.url)
}
}
}
}

View File

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.SearchActivity">
tools:context="com.keylesspalace.tusky.components.search.SearchActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
@ -19,12 +19,21 @@
android:layout_height="?attr/actionBarSize"
android:background="?attr/toolbar_background_color"
app:contentInsetStartWithNavigation="0dp"
app:layout_scrollFlags="scroll|snap|enterAlways"
app:navigationIcon="?attr/homeAsUpIndicator" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
style="@style/TuskyTabAppearance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/toolbar_background_color"
app:tabGravity="fill"
app:tabMode="fixed"
app:tabTextAppearance="@style/TuskyTabAppearance"/>
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/fragment_container"
<androidx.viewpager.widget.ViewPager
android:id="@+id/pages"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />

View File

@ -1,38 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:id="@+id/layoutRoot"
android:layout_width="@dimen/timeline_width"
android:layout_height="match_parent"
android:background="?attr/tab_page_margin_drawable">
<FrameLayout
android:layout_width="@dimen/timeline_width"
android:layout_height="match_parent"
android:layout_gravity="center">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/searchRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
tools:listitem="@layout/item_account"
android:background="?attr/window_background" />
android:id="@+id/searchRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="?attr/window_background"
tools:listitem="@layout/item_account"/>
<ProgressBar
android:id="@+id/searchProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/searchNoResultsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/search_no_results"
android:visibility="gone" />
</FrameLayout>
<ProgressBar
android:id="@+id/searchProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/searchNoResultsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/search_no_results"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progressBarBottom"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:indeterminate="true"
android:visibility="gone" />
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/account_container"
android:layout_width="match_parent"
android:background="?attr/selectableItemBackground"
android:layout_height="72dp"
android:paddingStart="16dp"
android:paddingEnd="16dp">

View File

@ -4,4 +4,5 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackground"
android:textSize="?attr/status_text_medium" />

View File

@ -513,6 +513,8 @@
<string name="failed_fetch_statuses">Failed to fetch statuses</string>
<string name="report_description_1">The report will be sent to your server moderator. You can provide an explanation of why you are reporting this account below:</string>
<string name="report_description_remote_instance">The account is from another server. Send an anonymized copy of the report there as well?</string>
<string name="title_accounts">Accounts</string>
<string name="failed_search">Failed to search</string>
<string name="pref_title_show_notifications_filter">Show Notifications filter</string>
</resources>