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