diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 30b1a7965..aff78f232 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,33 +7,34 @@
-
-
-
+
+
-
+
+
+ android:resource="@xml/shortcuts" />
-
+ android:configChanges="orientation|screenSize|keyboardHidden"/>
@@ -53,98 +54,110 @@
android:configChanges="orientation|screenSize|keyboardHidden">
+
+
+
+
+
+
+
+
+
+
+
+ android:value="com.keylesspalace.tusky.service.AccountChooserService" />
-
+ android:windowSoftInputMode="stateVisible|adjustResize"/>
-
-
+
+
-
-
+
+
-
-
+ android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
+ tools:targetApi="24">
+ tools:targetApi="23">
@@ -161,4 +174,4 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
index 111d7fae4..de12e4e35 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java
@@ -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);
diff --git a/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java b/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java
deleted file mode 100644
index 5fc5a7cdd..000000000
--- a/app/src/main/java/com/keylesspalace/tusky/SearchActivity.java
+++ /dev/null
@@ -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 . */
-
-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 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 androidInjector() {
- return androidInjector;
- }
-
-}
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java
index 4fdc68509..119669a99 100644
--- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java
@@ -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));
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/HashtagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/HashtagViewHolder.kt
new file mode 100644
index 000000000..c70076c83
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/HashtagViewHolder.kt
@@ -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) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
index e456600aa..4b5c93907 100644
--- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
@@ -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);
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
index d16030d6f..ade00fefa 100644
--- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
@@ -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);
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt
new file mode 100644
index 000000000..e7ae58017
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt
@@ -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 . */
+
+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
+
+ @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? {
+ return androidInjector
+ }
+
+ companion object {
+ @JvmStatic
+ fun getIntent(context: Context) = Intent(context, SearchActivity::class.java)
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt
new file mode 100644
index 000000000..5df657449
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt
@@ -0,0 +1,7 @@
+package com.keylesspalace.tusky.components.search
+
+enum class SearchType(val apiParameter: String) {
+ Status("statuses"),
+ Account("accounts"),
+ Hashtag("hashtags")
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
new file mode 100644
index 000000000..0974b03ce
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
@@ -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>(mastodonApi)
+ private val accountsRepository = SearchRepository(mastodonApi)
+ private val hashtagsRepository = SearchRepository(mastodonApi)
+ var alwaysShowSensitiveMedia: Boolean = activeAccount?.alwaysShowSensitiveMedia
+ ?: false
+
+ private val repoResultStatus = MutableLiveData>>()
+ val statuses: LiveData>> = Transformations.switchMap(repoResultStatus) { it.pagedList }
+ val networkStateStatus: LiveData = Transformations.switchMap(repoResultStatus) { it.networkState }
+ val networkStateStatusRefresh: LiveData = Transformations.switchMap(repoResultStatus) { it.refreshState }
+
+ private val repoResultAccount = MutableLiveData>()
+ val accounts: LiveData> = Transformations.switchMap(repoResultAccount) { it.pagedList }
+ val networkStateAccount: LiveData = Transformations.switchMap(repoResultAccount) { it.networkState }
+ val networkStateAccountRefresh: LiveData = Transformations.switchMap(repoResultAccount) { it.refreshState }
+
+ private val repoResultHashTag = MutableLiveData>()
+ val hashtags: LiveData> = Transformations.switchMap(repoResultHashTag) { it.pagedList }
+ val networkStateHashTag: LiveData = Transformations.switchMap(repoResultHashTag) { it.networkState }
+ val networkStateHashTagRefresh: LiveData = Transformations.switchMap(repoResultHashTag) { it.refreshState }
+
+ private val loadedStatuses = ArrayList>()
+ 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) {
+ timelineCases.delete(status.first.id)
+ if (loadedStatuses.remove(status))
+ repoResultStatus.value?.refresh?.invoke()
+ }
+
+ fun expandedChange(status: Pair, 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, 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, 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, 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, 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, choices: MutableList) {
+ 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, 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, 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 {
+ 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"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt
new file mode 100644
index 000000000..8c5c84c65
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt
@@ -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 . */
+
+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(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() {
+ override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean =
+ oldItem.deepEquals(newItem)
+
+ override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
+ oldItem.id == newItem.id
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt
new file mode 100644
index 000000000..8035beb40
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSource.kt
@@ -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 . */
+
+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(
+ private val mastodonApi: MastodonApi,
+ private val searchType: SearchType,
+ private val searchRequest: String?,
+ private val disposables: CompositeDisposable,
+ private val retryExecutor: Executor,
+ private val initialItems: List? = null,
+ private val parser: (SearchResults2?) -> List) : PositionalDataSource() {
+
+ val networkState = MutableLiveData()
+
+ private var retry: (() -> Any)? = null
+
+ val initialLoad = MutableLiveData()
+
+ fun retry() {
+ retry?.let {
+ retryExecutor.execute {
+ it.invoke()
+ }
+ }
+ }
+
+ @SuppressLint("CheckResult")
+ override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) {
+ 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) {
+ 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))
+ }
+ )
+
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt
new file mode 100644
index 000000000..2ea582953
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchDataSourceFactory.kt
@@ -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 . */
+
+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(
+ private val mastodonApi: MastodonApi,
+ private val searchType: SearchType,
+ private val searchRequest: String?,
+ private val disposables: CompositeDisposable,
+ private val retryExecutor: Executor,
+ private val cacheData: List? = null,
+ private val parser: (SearchResults2?) -> List) : DataSource.Factory() {
+ val sourceLiveData = MutableLiveData>()
+ override fun create(): DataSource {
+ val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser)
+ sourceLiveData.postValue(source)
+ return source
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt
new file mode 100644
index 000000000..7f172a379
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt
@@ -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 . */
+
+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(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() {
+ override fun areContentsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
+ oldItem.name == newItem.name
+
+ override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
+ oldItem.name == newItem.name
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt
new file mode 100644
index 000000000..d78d85bd2
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt
@@ -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 . */
+
+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
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt
new file mode 100644
index 000000000..954c0315a
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchRepository.kt
@@ -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 . */
+
+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(private val mastodonApi: MastodonApi) {
+
+ private val executor = Executors.newSingleThreadExecutor()
+
+ fun getSearchData(searchType: SearchType, searchRequest: String?, disposables: CompositeDisposable, pageSize: Int = 20,
+ initialItems: List? = null, parser: (SearchResults2?) -> List): Listing {
+ 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
+ }
+
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt
new file mode 100644
index 000000000..0be27308b
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt
@@ -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 . */
+
+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, 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? {
+ return super.getItem(position)
+ }
+
+ companion object {
+
+ val STATUS_COMPARATOR = object : DiffUtil.ItemCallback>() {
+ override fun areContentsTheSame(oldItem: Pair, newItem: Pair): Boolean =
+ oldItem.second.deepEquals(newItem.second)
+
+ override fun areItemsTheSame(oldItem: Pair, newItem: Pair): Boolean =
+ oldItem.second.id == newItem.second.id
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt
new file mode 100644
index 000000000..714580f7c
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt
@@ -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 . */
+
+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() {
+ override fun createAdapter(): PagedListAdapter = SearchAccountsAdapter(this)
+
+ override val networkStateRefresh: LiveData
+ get() = viewModel.networkStateAccountRefresh
+ override val networkState: LiveData
+ get() = viewModel.networkStateAccount
+ override val data: LiveData>
+ get() = viewModel.accounts
+
+ companion object {
+ fun newInstance() = SearchAccountsFragment()
+ }
+
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt
new file mode 100644
index 000000000..e003d6cf1
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt
@@ -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 : 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
+
+ abstract val networkStateRefresh: LiveData
+ abstract val networkState: LiveData
+ abstract val data: LiveData>
+ protected lateinit var adapter: PagedListAdapter
+
+ 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()
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt
new file mode 100644
index 000000000..15310d3c1
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt
@@ -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 . */
+
+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() {
+ override val networkStateRefresh: LiveData
+ get() = viewModel.networkStateHashTagRefresh
+ override val networkState: LiveData
+ get() = viewModel.networkStateHashTag
+ override val data: LiveData>
+ get() = viewModel.hashtags
+
+ override fun createAdapter(): PagedListAdapter = SearchHashtagsAdapter(this)
+
+ companion object {
+ fun newInstance() = SearchHashtagsFragment()
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt
new file mode 100644
index 000000000..ce0d557f8
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt
@@ -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 . */
+
+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>(), StatusActionListener {
+
+ override val networkStateRefresh: LiveData
+ get() = viewModel.networkStateStatusRefresh
+ override val networkState: LiveData
+ get() = viewModel.networkStateStatus
+ override val data: LiveData>>
+ get() = viewModel.statuses
+
+ override fun createAdapter(): PagedListAdapter, *> {
+ 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) {
+ (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()
+ 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): 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()
+ }
+
+
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
index 4d6e1eaf1..ed4151b22 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
@@ -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
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
index 21101fb1e..48e8b6250 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
@@ -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
+
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
index 2a3605a51..3706bc11d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
@@ -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
}
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt
new file mode 100644
index 000000000..1eaaf68f9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt
@@ -0,0 +1,3 @@
+package com.keylesspalace.tusky.entity
+
+data class HashTag(val name: String)
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/History.kt b/app/src/main/java/com/keylesspalace/tusky/entity/History.kt
new file mode 100644
index 000000000..b92975ba9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/History.kt
@@ -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
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResults2.kt b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResults2.kt
new file mode 100644
index 000000000..7ad37a451
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResults2.kt
@@ -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 . */
+
+package com.keylesspalace.tusky.entity
+
+data class SearchResults2 (
+ val accounts: List,
+ val statuses: List,
+ val hashtags: List
+)
diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt
deleted file mode 100644
index 075b23766..000000000
--- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt
+++ /dev/null
@@ -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 . */
-
-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 {
- override fun onResponse(call: Call, response: Response) {
- 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, 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) {
- 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"
- }
-
-}
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
index 4890ca89d..71606d3aa 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
@@ -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 statusObservable(@Path("id") String statusId);
+ @GET("api/v2/search")
+ Single 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);
+
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MultiListing.kt b/app/src/main/java/com/keylesspalace/tusky/util/MultiListing.kt
new file mode 100644
index 000000000..550c1a934
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/util/MultiListing.kt
@@ -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>>,
+ // represents the network request status to show to the user
+ val networkState: LiveData,
+ // represents the refresh status to show to the user. Separate from networkState, this
+ // value is importantly only when refresh is requested.
+ val refreshState: LiveData,
+ // refreshes the whole data and fetches it from scratch.
+ val refresh: () -> Unit,
+ // retries any failed requests.
+ val retry: () -> Unit)
\ No newline at end of file
diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt
index 0a5b36178..52eb31ac5 100644
--- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt
@@ -19,5 +19,12 @@ data class AttachmentViewData(
AttachmentViewData(it, actionable.id, actionable.url!!)
}
}
+
+ fun list(attachments: List): List {
+ return attachments.map {
+ AttachmentViewData(it, it.id, it.url)
+ }
+ }
+
}
}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml
index 55ad36667..992c2bd10 100644
--- a/app/src/main/res/layout/activity_search.xml
+++ b/app/src/main/res/layout/activity_search.xml
@@ -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">
-
+
-
diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml
index c5f898685..4381e5993 100644
--- a/app/src/main/res/layout/fragment_search.xml
+++ b/app/src/main/res/layout/fragment_search.xml
@@ -1,38 +1,50 @@
-
-
+
+ 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"/>
-
+
-
-
+
+
+
+
+
+
+
-
diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml
index 12c3071ee..45114bb6d 100644
--- a/app/src/main/res/layout/item_account.xml
+++ b/app/src/main/res/layout/item_account.xml
@@ -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">
diff --git a/app/src/main/res/layout/item_hashtag.xml b/app/src/main/res/layout/item_hashtag.xml
index 7f2066410..a158240c9 100644
--- a/app/src/main/res/layout/item_hashtag.xml
+++ b/app/src/main/res/layout/item_hashtag.xml
@@ -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" />
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6f5bf23fa..62264c018 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -513,6 +513,8 @@
Failed to fetch statuses
The report will be sent to your server moderator. You can provide an explanation of why you are reporting this account below:
The account is from another server. Send an anonymized copy of the report there as well?
+ Accounts
+ Failed to search
Show Notifications filter