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