Improve search results (#1327)
* Add entities and request for search APIv2 * Implement search adapter and fragment * Fix issue with snackbar * Implement search accounts fragment * Implement generic search fragment * Remove unneeded import * Implement "status" actions, fix issues * Remove SFragment dependency * Update app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt Co-Authored-By: Konrad Pozniak <connyduck@users.noreply.github.com> * Clean-up post review suggestions * Make TabLayout background colour match search bar * Corrected method call syntax * Added SwipeRefreshLayout to SearchFragment * Fixed refresh to update all three tabs
This commit is contained in:
parent
786a399bcd
commit
3b1288e99c
36 changed files with 1666 additions and 489 deletions
|
@ -7,33 +7,34 @@
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" /> <!-- For notifications -->
|
<uses-permission android:name="android.permission.VIBRATE" /> <!-- For notifications -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="22"/> <!-- for day/night mode -->
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- For sending toots with foreground service -->
|
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||||
|
android:maxSdkVersion="22" /> <!-- for day/night mode -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".TuskyApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name=".TuskyApplication"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/TuskyTheme">
|
android:theme="@style/TuskyTheme">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".SplashActivity"
|
android:name=".SplashActivity"
|
||||||
android:theme="@style/SplashTheme">
|
android:theme="@style/SplashTheme">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".SavedTootActivity"
|
android:name=".SavedTootActivity"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
android:configChanges="orientation|screenSize|keyboardHidden"/>
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LoginActivity"
|
android:name=".LoginActivity"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
@ -53,47 +54,58 @@
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
<data android:mimeType="image/*" />
|
<data android:mimeType="image/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
<data android:mimeType="image/*" />
|
<data android:mimeType="image/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.service.chooser.chooser_target_service"
|
android:name="android.service.chooser.chooser_target_service"
|
||||||
android:value="com.keylesspalace.tusky.service.AccountChooserService"
|
android:value="com.keylesspalace.tusky.service.AccountChooserService" />
|
||||||
/>
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ComposeActivity"
|
android:name=".ComposeActivity"
|
||||||
android:theme="@style/TuskyDialogActivityTheme"
|
android:theme="@style/TuskyDialogActivityTheme"
|
||||||
android:windowSoftInputMode="stateVisible|adjustResize">
|
android:windowSoftInputMode="stateVisible|adjustResize"/>
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ViewThreadActivity"
|
android:name=".ViewThreadActivity"
|
||||||
android:configChanges="orientation|screenSize" />
|
android:configChanges="orientation|screenSize" />
|
||||||
<activity android:name=".ViewTagActivity" />
|
<activity android:name=".ViewTagActivity" />
|
||||||
<activity android:name=".ViewMediaActivity"
|
<activity
|
||||||
android:theme="@style/TuskyBaseTheme"
|
android:name=".ViewMediaActivity"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden"/>
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
<activity android:name=".AccountActivity"
|
android:theme="@style/TuskyBaseTheme" />
|
||||||
|
<activity
|
||||||
|
android:name=".AccountActivity"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||||
<activity android:name=".EditProfileActivity" />
|
<activity android:name=".EditProfileActivity" />
|
||||||
<activity android:name=".PreferencesActivity" />
|
<activity android:name=".PreferencesActivity" />
|
||||||
|
@ -101,50 +113,51 @@
|
||||||
<activity android:name=".AccountListActivity" />
|
<activity android:name=".AccountListActivity" />
|
||||||
<activity android:name=".AboutActivity" />
|
<activity android:name=".AboutActivity" />
|
||||||
<activity android:name=".TabPreferenceActivity" />
|
<activity android:name=".TabPreferenceActivity" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.theartofdev.edmodo.cropper.CropImageActivity"
|
android:name="com.theartofdev.edmodo.cropper.CropImageActivity"
|
||||||
android:theme="@style/Base.Theme.AppCompat" />
|
android:theme="@style/Base.Theme.AppCompat" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".SearchActivity"
|
android:name=".components.search.SearchActivity"
|
||||||
android:launchMode="singleTop">
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEARCH" />
|
<action android:name="android.intent.action.SEARCH" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable" />
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.searchable"
|
||||||
|
android:resource="@xml/searchable" />
|
||||||
</activity>
|
</activity>
|
||||||
<activity android:name=".ListsActivity" />
|
<activity android:name=".ListsActivity" />
|
||||||
<activity android:name=".ModalTimelineActivity" />
|
<activity android:name=".ModalTimelineActivity" />
|
||||||
<activity android:name=".LicenseActivity" />
|
<activity android:name=".LicenseActivity" />
|
||||||
<activity android:name=".FiltersActivity" />
|
<activity android:name=".FiltersActivity" />
|
||||||
<activity android:name=".components.report.ReportActivity"
|
<activity
|
||||||
|
android:name=".components.report.ReportActivity"
|
||||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||||
<activity android:name=".components.instancemute.InstanceListActivity" />
|
<activity android:name=".components.instancemute.InstanceListActivity" />
|
||||||
|
|
||||||
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
|
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
tools:targetApi="24"
|
android:name=".service.TuskyTileService"
|
||||||
android:name="com.keylesspalace.tusky.service.TuskyTileService"
|
|
||||||
android:icon="@drawable/ic_tusky"
|
android:icon="@drawable/ic_tusky"
|
||||||
android:label="Compose Toot"
|
android:label="Compose Toot"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||||
|
tools:targetApi="24">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service android:name=".service.SendTootService" />
|
<service android:name=".service.SendTootService" />
|
||||||
<service
|
<service
|
||||||
tools:targetApi="23"
|
android:name=".service.AccountChooserService"
|
||||||
android:name="com.keylesspalace.tusky.service.AccountChooserService"
|
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE"
|
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE"
|
||||||
>
|
tools:targetApi="23">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.chooser.ChooserTargetService" />
|
<action android:name="android.service.chooser.ChooserTargetService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
|
@ -47,6 +47,7 @@ import com.keylesspalace.tusky.appstore.EventHub;
|
||||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
|
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
|
||||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
|
import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsRepository;
|
import com.keylesspalace.tusky.components.conversation.ConversationsRepository;
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchActivity;
|
||||||
import com.keylesspalace.tusky.db.AccountEntity;
|
import com.keylesspalace.tusky.db.AccountEntity;
|
||||||
import com.keylesspalace.tusky.entity.Account;
|
import com.keylesspalace.tusky.entity.Account;
|
||||||
import com.keylesspalace.tusky.fragment.SFragment;
|
import com.keylesspalace.tusky.fragment.SFragment;
|
||||||
|
@ -286,7 +287,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case KeyEvent.KEYCODE_SEARCH: {
|
case KeyEvent.KEYCODE_SEARCH: {
|
||||||
startActivityWithSlideInAnimation(new Intent(this, SearchActivity.class));
|
startActivityWithSlideInAnimation(SearchActivity.getIntent(this));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -413,8 +414,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
|
||||||
Intent intent = new Intent(MainActivity.this, FavouritesActivity.class);
|
Intent intent = new Intent(MainActivity.this, FavouritesActivity.class);
|
||||||
startActivityWithSlideInAnimation(intent);
|
startActivityWithSlideInAnimation(intent);
|
||||||
} else if (drawerItemIdentifier == DRAWER_ITEM_SEARCH) {
|
} else if (drawerItemIdentifier == DRAWER_ITEM_SEARCH) {
|
||||||
Intent intent = new Intent(MainActivity.this, SearchActivity.class);
|
startActivityWithSlideInAnimation(SearchActivity.getIntent(this));
|
||||||
startActivityWithSlideInAnimation(intent);
|
|
||||||
} else if (drawerItemIdentifier == DRAWER_ITEM_ACCOUNT_SETTINGS) {
|
} else if (drawerItemIdentifier == DRAWER_ITEM_ACCOUNT_SETTINGS) {
|
||||||
Intent intent = PreferencesActivity.newIntent(MainActivity.this, PreferencesActivity.ACCOUNT_PREFERENCES);
|
Intent intent = PreferencesActivity.newIntent(MainActivity.this, PreferencesActivity.ACCOUNT_PREFERENCES);
|
||||||
startActivityWithSlideInAnimation(intent);
|
startActivityWithSlideInAnimation(intent);
|
||||||
|
|
|
@ -1,143 +0,0 @@
|
||||||
/* Copyright 2017 Andrew Dawson
|
|
||||||
*
|
|
||||||
* This file is a part of Tusky.
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
||||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
||||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
||||||
* Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
||||||
* see <http://www.gnu.org/licenses>. */
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky;
|
|
||||||
|
|
||||||
import android.app.SearchManager;
|
|
||||||
import android.app.SearchableInfo;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import androidx.appcompat.widget.SearchView;
|
|
||||||
import androidx.appcompat.widget.Toolbar;
|
|
||||||
import androidx.fragment.app.FragmentTransaction;
|
|
||||||
|
|
||||||
import com.keylesspalace.tusky.fragment.SearchFragment;
|
|
||||||
|
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import dagger.android.AndroidInjector;
|
|
||||||
import dagger.android.DispatchingAndroidInjector;
|
|
||||||
import dagger.android.HasAndroidInjector;
|
|
||||||
|
|
||||||
public class SearchActivity extends BottomSheetActivity implements SearchView.OnQueryTextListener,
|
|
||||||
HasAndroidInjector {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
public DispatchingAndroidInjector<Object> androidInjector;
|
|
||||||
|
|
||||||
private String currentQuery;
|
|
||||||
|
|
||||||
private SearchFragment searchFragment;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.activity_search);
|
|
||||||
|
|
||||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
|
||||||
setSupportActionBar(toolbar);
|
|
||||||
ActionBar bar = getSupportActionBar();
|
|
||||||
if (bar != null) {
|
|
||||||
bar.setDisplayHomeAsUpEnabled(true);
|
|
||||||
bar.setDisplayShowHomeEnabled(true);
|
|
||||||
bar.setDisplayShowTitleEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchFragment = new SearchFragment();
|
|
||||||
|
|
||||||
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
|
|
||||||
fragmentTransaction.replace(R.id.fragment_container, searchFragment);
|
|
||||||
fragmentTransaction.commit();
|
|
||||||
|
|
||||||
handleIntent(getIntent());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onNewIntent(Intent intent) {
|
|
||||||
super.onNewIntent(intent);
|
|
||||||
handleIntent(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCreateOptionsMenu(Menu menu) {
|
|
||||||
super.onCreateOptionsMenu(menu);
|
|
||||||
|
|
||||||
getMenuInflater().inflate(R.menu.search_toolbar, menu);
|
|
||||||
SearchView searchView = (SearchView) menu.findItem(R.id.action_search)
|
|
||||||
.getActionView();
|
|
||||||
setupSearchView(searchView);
|
|
||||||
|
|
||||||
if (currentQuery != null) {
|
|
||||||
searchView.setQuery(currentQuery, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item) {
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case android.R.id.home: {
|
|
||||||
onBackPressed();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onQueryTextChange(String newText) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onQueryTextSubmit(String query) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleIntent(Intent intent) {
|
|
||||||
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
|
|
||||||
currentQuery = intent.getStringExtra(SearchManager.QUERY);
|
|
||||||
searchFragment.search(currentQuery);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupSearchView(SearchView searchView) {
|
|
||||||
searchView.setIconifiedByDefault(false);
|
|
||||||
|
|
||||||
SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
|
|
||||||
if (searchManager != null) {
|
|
||||||
SearchableInfo searchableInfo = searchManager.getSearchableInfo(getComponentName());
|
|
||||||
searchView.setSearchableInfo(searchableInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchView.setOnQueryTextListener(this);
|
|
||||||
searchView.requestFocus();
|
|
||||||
|
|
||||||
searchView.setMaxWidth(Integer.MAX_VALUE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AndroidInjector<Object> androidInjector() {
|
|
||||||
return androidInjector;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -15,7 +15,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||||
|
|
||||||
class AccountViewHolder extends RecyclerView.ViewHolder {
|
public class AccountViewHolder extends RecyclerView.ViewHolder {
|
||||||
private TextView username;
|
private TextView username;
|
||||||
private TextView displayName;
|
private TextView displayName;
|
||||||
private ImageView avatar;
|
private ImageView avatar;
|
||||||
|
@ -24,7 +24,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
|
||||||
private boolean showBotOverlay;
|
private boolean showBotOverlay;
|
||||||
private boolean animateAvatar;
|
private boolean animateAvatar;
|
||||||
|
|
||||||
AccountViewHolder(View itemView) {
|
public AccountViewHolder(View itemView) {
|
||||||
super(itemView);
|
super(itemView);
|
||||||
username = itemView.findViewById(R.id.account_username);
|
username = itemView.findViewById(R.id.account_username);
|
||||||
displayName = itemView.findViewById(R.id.account_display_name);
|
displayName = itemView.findViewById(R.id.account_display_name);
|
||||||
|
@ -35,7 +35,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
|
||||||
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false);
|
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupWithAccount(Account account) {
|
public void setupWithAccount(Account account) {
|
||||||
accountId = account.getId();
|
accountId = account.getId();
|
||||||
String format = username.getContext().getString(R.string.status_username_format);
|
String format = username.getContext().getString(R.string.status_username_format);
|
||||||
String formattedUsername = String.format(format, account.getUsername());
|
String formattedUsername = String.format(format, account.getUsername());
|
||||||
|
@ -58,7 +58,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
|
||||||
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupLinkListener(final LinkListener listener) {
|
public void setupLinkListener(final LinkListener listener) {
|
||||||
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.keylesspalace.tusky.adapter
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
|
||||||
|
class HashtagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
private val hashtag: TextView = itemView.findViewById(R.id.hashtag)
|
||||||
|
|
||||||
|
fun setup(tag: String, listener: LinkListener) {
|
||||||
|
hashtag.text = String.format("#%s", tag)
|
||||||
|
hashtag.setOnClickListener { listener.onViewTag(tag) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -607,7 +607,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
itemView.setOnClickListener(viewThreadListener);
|
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) {
|
boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar) {
|
||||||
this.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, null);
|
this.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, null);
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
||||||
private TextView statusInfo;
|
private TextView statusInfo;
|
||||||
private ToggleButton contentCollapseButton;
|
private ToggleButton contentCollapseButton;
|
||||||
|
|
||||||
StatusViewHolder(View itemView, boolean useAbsoluteTime) {
|
public StatusViewHolder(View itemView, boolean useAbsoluteTime) {
|
||||||
super(itemView, useAbsoluteTime);
|
super(itemView, useAbsoluteTime);
|
||||||
statusInfo = itemView.findViewById(R.id.status_info);
|
statusInfo = itemView.findViewById(R.id.status_info);
|
||||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
/* Copyright 2017 Andrew Dawson
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search
|
||||||
|
|
||||||
|
import android.app.SearchManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import com.keylesspalace.tusky.BottomSheetActivity
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter
|
||||||
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
|
import dagger.android.AndroidInjector
|
||||||
|
import dagger.android.DispatchingAndroidInjector
|
||||||
|
import dagger.android.HasAndroidInjector
|
||||||
|
import kotlinx.android.synthetic.main.activity_search.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SearchActivity : BottomSheetActivity(), SearchView.OnQueryTextListener, HasAndroidInjector {
|
||||||
|
@Inject
|
||||||
|
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
private lateinit var viewModel: SearchViewModel
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_search)
|
||||||
|
viewModel = ViewModelProviders.of(this, viewModelFactory)[SearchViewModel::class.java]
|
||||||
|
setSupportActionBar(toolbar)
|
||||||
|
supportActionBar?.apply {
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
setDisplayShowHomeEnabled(true)
|
||||||
|
setDisplayShowTitleEnabled(false)
|
||||||
|
}
|
||||||
|
setupPages()
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPages() {
|
||||||
|
pages.adapter = SearchPagerAdapter(this, supportFragmentManager)
|
||||||
|
tabs.setupWithViewPager(pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
super.onCreateOptionsMenu(menu)
|
||||||
|
|
||||||
|
menuInflater.inflate(R.menu.search_toolbar, menu)
|
||||||
|
val searchView = menu.findItem(R.id.action_search)
|
||||||
|
.actionView as SearchView
|
||||||
|
setupSearchView(searchView)
|
||||||
|
|
||||||
|
if (viewModel.currentQuery != null) {
|
||||||
|
searchView.setQuery(viewModel.currentQuery, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
onBackPressed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQueryTextChange(newText: String): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQueryTextSubmit(query: String): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIntent(intent: Intent) {
|
||||||
|
if (Intent.ACTION_SEARCH == intent.action) {
|
||||||
|
viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY)
|
||||||
|
viewModel.search(viewModel.currentQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSearchView(searchView: SearchView) {
|
||||||
|
searchView.setIconifiedByDefault(false)
|
||||||
|
|
||||||
|
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
|
||||||
|
|
||||||
|
searchView.setOnQueryTextListener(this)
|
||||||
|
searchView.requestFocus()
|
||||||
|
|
||||||
|
searchView.maxWidth = Integer.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun androidInjector(): AndroidInjector<Any>? {
|
||||||
|
return androidInjector
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun getIntent(context: Context) = Intent(context, SearchActivity::class.java)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.keylesspalace.tusky.components.search
|
||||||
|
|
||||||
|
enum class SearchType(val apiParameter: String) {
|
||||||
|
Status("statuses"),
|
||||||
|
Account("accounts"),
|
||||||
|
Hashtag("hashtags")
|
||||||
|
}
|
|
@ -0,0 +1,211 @@
|
||||||
|
package com.keylesspalace.tusky.components.search
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.Transformations
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.paging.PagedList
|
||||||
|
import com.keylesspalace.tusky.components.search.adapter.SearchRepository
|
||||||
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
import com.keylesspalace.tusky.entity.Account
|
||||||
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.network.TimelineCases
|
||||||
|
import com.keylesspalace.tusky.util.Listing
|
||||||
|
import com.keylesspalace.tusky.util.NetworkState
|
||||||
|
import com.keylesspalace.tusky.util.ViewDataUtils
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
class SearchViewModel @Inject constructor(
|
||||||
|
mastodonApi: MastodonApi,
|
||||||
|
private val timelineCases: TimelineCases,
|
||||||
|
private val accountManager: AccountManager) : ViewModel() {
|
||||||
|
|
||||||
|
var currentQuery: String? = null
|
||||||
|
|
||||||
|
var activeAccount: AccountEntity?
|
||||||
|
get() = accountManager.activeAccount
|
||||||
|
set(value) {
|
||||||
|
accountManager.activeAccount = value
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaPreviewEnabled: Boolean
|
||||||
|
get() = activeAccount?.mediaPreviewEnabled ?: false
|
||||||
|
private val disposables = CompositeDisposable()
|
||||||
|
|
||||||
|
private val statusesRepository = SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
||||||
|
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
||||||
|
private val hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
|
||||||
|
var alwaysShowSensitiveMedia: Boolean = activeAccount?.alwaysShowSensitiveMedia
|
||||||
|
?: false
|
||||||
|
|
||||||
|
private val repoResultStatus = MutableLiveData<Listing<Pair<Status, StatusViewData.Concrete>>>()
|
||||||
|
val statuses: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>> = Transformations.switchMap(repoResultStatus) { it.pagedList }
|
||||||
|
val networkStateStatus: LiveData<NetworkState> = Transformations.switchMap(repoResultStatus) { it.networkState }
|
||||||
|
val networkStateStatusRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResultStatus) { it.refreshState }
|
||||||
|
|
||||||
|
private val repoResultAccount = MutableLiveData<Listing<Account>>()
|
||||||
|
val accounts: LiveData<PagedList<Account>> = Transformations.switchMap(repoResultAccount) { it.pagedList }
|
||||||
|
val networkStateAccount: LiveData<NetworkState> = Transformations.switchMap(repoResultAccount) { it.networkState }
|
||||||
|
val networkStateAccountRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResultAccount) { it.refreshState }
|
||||||
|
|
||||||
|
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
|
||||||
|
val hashtags: LiveData<PagedList<HashTag>> = Transformations.switchMap(repoResultHashTag) { it.pagedList }
|
||||||
|
val networkStateHashTag: LiveData<NetworkState> = Transformations.switchMap(repoResultHashTag) { it.networkState }
|
||||||
|
val networkStateHashTagRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResultHashTag) { it.refreshState }
|
||||||
|
|
||||||
|
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
|
||||||
|
fun search(query: String?) {
|
||||||
|
loadedStatuses.clear()
|
||||||
|
repoResultStatus.value = statusesRepository.getSearchData(SearchType.Status, query, disposables, initialItems = loadedStatuses) {
|
||||||
|
(it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia)!!) }
|
||||||
|
?: emptyList())
|
||||||
|
.apply {
|
||||||
|
loadedStatuses.addAll(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) {
|
||||||
|
it?.accounts ?: emptyList()
|
||||||
|
}
|
||||||
|
repoResultHashTag.value = hashtagsRepository.getSearchData(SearchType.Hashtag, String.format(Locale.getDefault(),"#%s",query), disposables) {
|
||||||
|
it?.hashtags ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
disposables.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
||||||
|
timelineCases.delete(status.first.id)
|
||||||
|
if (loadedStatuses.remove(status))
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
|
||||||
|
val idx = loadedStatuses.indexOf(status)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData())
|
||||||
|
loadedStatuses[idx] = newPair
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||||
|
disposables.add(timelineCases.reblog(status.first, reblog)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ setRebloggedForStatus(status, reblog) },
|
||||||
|
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||||
|
status.first.reblogged = reblog
|
||||||
|
status.first.reblog?.reblogged = reblog
|
||||||
|
|
||||||
|
val idx = loadedStatuses.indexOf(status)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData())
|
||||||
|
loadedStatuses[idx] = newPair
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
|
||||||
|
val idx = loadedStatuses.indexOf(status)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData())
|
||||||
|
loadedStatuses[idx] = newPair
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
|
||||||
|
val idx = loadedStatuses.indexOf(status)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData())
|
||||||
|
loadedStatuses[idx] = newPair
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
|
||||||
|
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
|
||||||
|
updateStatus(status, votedPoll)
|
||||||
|
disposables.add(timelineCases.voteInPoll(status.first, choices)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ newPoll -> updateStatus(status, newPoll) },
|
||||||
|
{ t ->
|
||||||
|
Log.d(TAG,
|
||||||
|
"Failed to vote in poll: ${status.first.id}", t)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
|
||||||
|
val idx = loadedStatuses.indexOf(status)
|
||||||
|
if (idx >= 0) {
|
||||||
|
|
||||||
|
val newViewData = StatusViewData.Builder(status.second)
|
||||||
|
.setPoll(newPoll)
|
||||||
|
.createStatusViewData()
|
||||||
|
loadedStatuses[idx] = Pair(status.first, newViewData)
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
|
||||||
|
val idx = loadedStatuses.indexOf(status)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData())
|
||||||
|
loadedStatuses[idx] = newPair
|
||||||
|
repoResultStatus.value?.refresh?.invoke()
|
||||||
|
}
|
||||||
|
disposables.add(timelineCases.favourite(status.first, isFavorited)
|
||||||
|
.onErrorReturnItem(status.first)
|
||||||
|
.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
|
||||||
|
return accountManager.getAllAccountsOrderedByActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun muteAcount(accountId: String) {
|
||||||
|
timelineCases.mute(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinAccount(status: Status, isPin: Boolean) {
|
||||||
|
timelineCases.pin(status, isPin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun blockAccount(accountId: String) {
|
||||||
|
timelineCases.block(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteStatus(id: String) {
|
||||||
|
timelineCases.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryAllSearches() {
|
||||||
|
search(currentQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SearchViewModel"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||||
|
import com.keylesspalace.tusky.entity.Account
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
|
||||||
|
class SearchAccountsAdapter(private val linkListener: LinkListener)
|
||||||
|
: PagedListAdapter<Account, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||||
|
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_account, parent, false)
|
||||||
|
return AccountViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
getItem(position)?.let { item ->
|
||||||
|
(holder as? AccountViewHolder)?.apply {
|
||||||
|
setupWithAccount(item)
|
||||||
|
setupLinkListener(linkListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun getItem(position: Int): Account? {
|
||||||
|
return super.getItem(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Account>() {
|
||||||
|
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean =
|
||||||
|
oldItem.deepEquals(newItem)
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean =
|
||||||
|
oldItem.id == newItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.paging.PositionalDataSource
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchType
|
||||||
|
import com.keylesspalace.tusky.entity.SearchResults2
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.util.NetworkState
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
class SearchDataSource<T>(
|
||||||
|
private val mastodonApi: MastodonApi,
|
||||||
|
private val searchType: SearchType,
|
||||||
|
private val searchRequest: String?,
|
||||||
|
private val disposables: CompositeDisposable,
|
||||||
|
private val retryExecutor: Executor,
|
||||||
|
private val initialItems: List<T>? = null,
|
||||||
|
private val parser: (SearchResults2?) -> List<T>) : PositionalDataSource<T>() {
|
||||||
|
|
||||||
|
val networkState = MutableLiveData<NetworkState>()
|
||||||
|
|
||||||
|
private var retry: (() -> Any)? = null
|
||||||
|
|
||||||
|
val initialLoad = MutableLiveData<NetworkState>()
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
retry?.let {
|
||||||
|
retryExecutor.execute {
|
||||||
|
it.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("CheckResult")
|
||||||
|
override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<T>) {
|
||||||
|
if (!initialItems.isNullOrEmpty()) {
|
||||||
|
callback.onResult(initialItems, 0)
|
||||||
|
} else {
|
||||||
|
networkState.postValue(NetworkState.LOADED)
|
||||||
|
retry = null
|
||||||
|
initialLoad.postValue(NetworkState.LOADING)
|
||||||
|
mastodonApi.searchObservable(searchType.apiParameter, searchRequest, true, params.requestedLoadSize, 0, false)
|
||||||
|
.doOnSubscribe {
|
||||||
|
disposables.add(it)
|
||||||
|
}
|
||||||
|
.subscribe(
|
||||||
|
{ data ->
|
||||||
|
val res = parser(data)
|
||||||
|
callback.onResult(res, params.requestedStartPosition)
|
||||||
|
initialLoad.postValue(NetworkState.LOADED)
|
||||||
|
|
||||||
|
},
|
||||||
|
{ error ->
|
||||||
|
retry = {
|
||||||
|
loadInitial(params, callback)
|
||||||
|
}
|
||||||
|
initialLoad.postValue(NetworkState.error(error.message))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("CheckResult")
|
||||||
|
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<T>) {
|
||||||
|
networkState.postValue(NetworkState.LOADING)
|
||||||
|
retry = null
|
||||||
|
mastodonApi.searchObservable(searchType.apiParameter, searchRequest, true, params.loadSize, params.startPosition, false)
|
||||||
|
.doOnSubscribe {
|
||||||
|
disposables.add(it)
|
||||||
|
}
|
||||||
|
.subscribe(
|
||||||
|
{ data ->
|
||||||
|
val res = parser(data)
|
||||||
|
callback.onResult(res)
|
||||||
|
networkState.postValue(NetworkState.LOADED)
|
||||||
|
|
||||||
|
},
|
||||||
|
{ error ->
|
||||||
|
retry = {
|
||||||
|
loadRange(params, callback)
|
||||||
|
}
|
||||||
|
networkState.postValue(NetworkState.error(error.message))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.paging.DataSource
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchType
|
||||||
|
import com.keylesspalace.tusky.entity.SearchResults2
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
class SearchDataSourceFactory<T>(
|
||||||
|
private val mastodonApi: MastodonApi,
|
||||||
|
private val searchType: SearchType,
|
||||||
|
private val searchRequest: String?,
|
||||||
|
private val disposables: CompositeDisposable,
|
||||||
|
private val retryExecutor: Executor,
|
||||||
|
private val cacheData: List<T>? = null,
|
||||||
|
private val parser: (SearchResults2?) -> List<T>) : DataSource.Factory<Int, T>() {
|
||||||
|
val sourceLiveData = MutableLiveData<SearchDataSource<T>>()
|
||||||
|
override fun create(): DataSource<Int, T> {
|
||||||
|
val source = SearchDataSource(mastodonApi, searchType, searchRequest, disposables, retryExecutor, cacheData, parser)
|
||||||
|
sourceLiveData.postValue(source)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.adapter.HashtagViewHolder
|
||||||
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
|
||||||
|
class SearchHashtagsAdapter(private val linkListener: LinkListener)
|
||||||
|
: PagedListAdapter<HashTag, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||||
|
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_hashtag, parent, false)
|
||||||
|
return HashtagViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
getItem(position)?.let { item ->
|
||||||
|
(holder as? HashtagViewHolder)?.apply {
|
||||||
|
setup(item.name, linkListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<HashTag>() {
|
||||||
|
override fun areContentsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
|
||||||
|
oldItem.name == newItem.name
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean =
|
||||||
|
oldItem.name == newItem.name
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.FragmentPagerAdapter
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchType
|
||||||
|
import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment
|
||||||
|
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
|
||||||
|
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
|
||||||
|
|
||||||
|
class SearchPagerAdapter(private val context: Context, manager: FragmentManager) : FragmentPagerAdapter(manager) {
|
||||||
|
override fun getItem(position: Int): Fragment {
|
||||||
|
return when (position) {
|
||||||
|
0 -> SearchStatusesFragment.newInstance()
|
||||||
|
1 -> SearchAccountsFragment.newInstance()
|
||||||
|
2 -> SearchHashtagsFragment.newInstance()
|
||||||
|
else -> throw IllegalArgumentException("Unknown page index: $position")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageTitle(position: Int): CharSequence? {
|
||||||
|
return when (position) {
|
||||||
|
0 -> context.getString(R.string.title_statuses)
|
||||||
|
1 -> context.getString(R.string.title_accounts)
|
||||||
|
2 -> context.getString(R.string.title_hashtags_dialog)
|
||||||
|
else -> throw IllegalArgumentException("Unknown page index: $position")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCount(): Int = 3
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import androidx.lifecycle.Transformations
|
||||||
|
import androidx.paging.Config
|
||||||
|
import androidx.paging.toLiveData
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchType
|
||||||
|
import com.keylesspalace.tusky.entity.SearchResults2
|
||||||
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
|
import com.keylesspalace.tusky.util.Listing
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class SearchRepository<T>(private val mastodonApi: MastodonApi) {
|
||||||
|
|
||||||
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
fun getSearchData(searchType: SearchType, searchRequest: String?, disposables: CompositeDisposable, pageSize: Int = 20,
|
||||||
|
initialItems: List<T>? = null, parser: (SearchResults2?) -> List<T>): Listing<T> {
|
||||||
|
val sourceFactory = SearchDataSourceFactory(mastodonApi, searchType, searchRequest, disposables, executor, initialItems, parser)
|
||||||
|
val livePagedList = sourceFactory.toLiveData(
|
||||||
|
config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2),
|
||||||
|
fetchExecutor = executor
|
||||||
|
)
|
||||||
|
return Listing(
|
||||||
|
pagedList = livePagedList,
|
||||||
|
networkState = Transformations.switchMap(sourceFactory.sourceLiveData) {
|
||||||
|
it.networkState
|
||||||
|
},
|
||||||
|
retry = {
|
||||||
|
sourceFactory.sourceLiveData.value?.retry()
|
||||||
|
},
|
||||||
|
refresh = {
|
||||||
|
sourceFactory.sourceLiveData.value?.invalidate()
|
||||||
|
},
|
||||||
|
refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) {
|
||||||
|
it.initialLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.adapter
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
|
||||||
|
class SearchStatusesAdapter(private val useAbsoluteTime: Boolean,
|
||||||
|
private val mediaPreviewEnabled: Boolean,
|
||||||
|
private val showBotOverlay: Boolean,
|
||||||
|
private val animateAvatar: Boolean,
|
||||||
|
private val statusListener: StatusActionListener)
|
||||||
|
: PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||||
|
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_status, parent, false)
|
||||||
|
return StatusViewHolder(view, useAbsoluteTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
getItem(position)?.let { item ->
|
||||||
|
(holder as? StatusViewHolder)?.setupWithStatus(item.second, statusListener,
|
||||||
|
mediaPreviewEnabled, showBotOverlay, animateAvatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun getItem(position: Int): Pair<Status, StatusViewData.Concrete>? {
|
||||||
|
return super.getItem(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
|
||||||
|
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||||
|
oldItem.second.deepEquals(newItem.second)
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
|
||||||
|
oldItem.second.id == newItem.second.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.fragments
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.paging.PagedList
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
|
||||||
|
import com.keylesspalace.tusky.entity.Account
|
||||||
|
import com.keylesspalace.tusky.util.NetworkState
|
||||||
|
|
||||||
|
class SearchAccountsFragment : SearchFragment<Account>() {
|
||||||
|
override fun createAdapter(): PagedListAdapter<Account, *> = SearchAccountsAdapter(this)
|
||||||
|
|
||||||
|
override val networkStateRefresh: LiveData<NetworkState>
|
||||||
|
get() = viewModel.networkStateAccountRefresh
|
||||||
|
override val networkState: LiveData<NetworkState>
|
||||||
|
get() = viewModel.networkStateAccount
|
||||||
|
override val data: LiveData<PagedList<Account>>
|
||||||
|
get() = viewModel.accounts
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance() = SearchAccountsFragment()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
package com.keylesspalace.tusky.components.search.fragments
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.paging.PagedList
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.keylesspalace.tusky.AccountActivity
|
||||||
|
import com.keylesspalace.tusky.BottomSheetActivity
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.ViewTagActivity
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||||
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||||
|
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||||
|
import com.keylesspalace.tusky.util.*
|
||||||
|
import kotlinx.android.synthetic.main.fragment_search.*
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
abstract class SearchFragment<T> : Fragment(),
|
||||||
|
LinkListener, Injectable, SwipeRefreshLayout.OnRefreshListener {
|
||||||
|
private var isSwipeToRefreshEnabled: Boolean = true
|
||||||
|
private var snackbarErrorRetry: Snackbar? = null
|
||||||
|
@Inject
|
||||||
|
lateinit var viewModelFactory: ViewModelFactory
|
||||||
|
|
||||||
|
protected lateinit var viewModel: SearchViewModel
|
||||||
|
|
||||||
|
abstract fun createAdapter(): PagedListAdapter<T, *>
|
||||||
|
|
||||||
|
abstract val networkStateRefresh: LiveData<NetworkState>
|
||||||
|
abstract val networkState: LiveData<NetworkState>
|
||||||
|
abstract val data: LiveData<PagedList<T>>
|
||||||
|
protected lateinit var adapter: PagedListAdapter<T, *>
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)[SearchViewModel::class.java]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_search, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
initAdapter()
|
||||||
|
setupSwipeRefreshLayout()
|
||||||
|
subscribeObservables()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSwipeRefreshLayout() {
|
||||||
|
swipeRefreshLayout.setOnRefreshListener(this)
|
||||||
|
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||||
|
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(
|
||||||
|
ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subscribeObservables() {
|
||||||
|
data.observe(viewLifecycleOwner, Observer {
|
||||||
|
adapter.submitList(it)
|
||||||
|
})
|
||||||
|
|
||||||
|
networkStateRefresh.observe(viewLifecycleOwner, Observer {
|
||||||
|
|
||||||
|
searchProgressBar.visible(it == NetworkState.LOADING)
|
||||||
|
|
||||||
|
if (it.status == Status.FAILED)
|
||||||
|
showError(it.msg)
|
||||||
|
checkNoData()
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
networkState.observe(viewLifecycleOwner, Observer {
|
||||||
|
|
||||||
|
progressBarBottom.visible(it == NetworkState.LOADING)
|
||||||
|
|
||||||
|
if (it.status == Status.FAILED)
|
||||||
|
showError(it.msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNoData() {
|
||||||
|
showNoData(adapter.itemCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initAdapter() {
|
||||||
|
searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||||
|
searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context)
|
||||||
|
adapter = createAdapter()
|
||||||
|
searchRecyclerView.adapter = adapter
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNoData(isEmpty: Boolean) {
|
||||||
|
if (isEmpty && networkStateRefresh.value == NetworkState.LOADED)
|
||||||
|
searchNoResultsText.show()
|
||||||
|
else
|
||||||
|
searchNoResultsText.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) {
|
||||||
|
if (snackbarErrorRetry?.isShown != true) {
|
||||||
|
snackbarErrorRetry = Snackbar.make(layoutRoot, R.string.failed_search, Snackbar.LENGTH_INDEFINITE)
|
||||||
|
snackbarErrorRetry?.setAction(R.string.action_retry) {
|
||||||
|
snackbarErrorRetry = null
|
||||||
|
viewModel.retryAllSearches()
|
||||||
|
}
|
||||||
|
snackbarErrorRetry?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
|
||||||
|
|
||||||
|
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
|
||||||
|
|
||||||
|
override fun onViewUrl(url: String) {
|
||||||
|
bottomSheetActivity?.viewUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected val bottomSheetActivity = (activity as? BottomSheetActivity)
|
||||||
|
|
||||||
|
override fun onRefresh() {
|
||||||
|
|
||||||
|
// Dismissed here because the RecyclerView bottomProgressBar is shown as soon as the retry begins.
|
||||||
|
Timer("DelayDismiss", false).schedule(200) {
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.retryAllSearches()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.fragments
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.paging.PagedList
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
|
||||||
|
import com.keylesspalace.tusky.entity.HashTag
|
||||||
|
import com.keylesspalace.tusky.util.NetworkState
|
||||||
|
|
||||||
|
class SearchHashtagsFragment : SearchFragment<HashTag>() {
|
||||||
|
override val networkStateRefresh: LiveData<NetworkState>
|
||||||
|
get() = viewModel.networkStateHashTagRefresh
|
||||||
|
override val networkState: LiveData<NetworkState>
|
||||||
|
get() = viewModel.networkStateHashTag
|
||||||
|
override val data: LiveData<PagedList<HashTag>>
|
||||||
|
get() = viewModel.hashtags
|
||||||
|
|
||||||
|
override fun createAdapter(): PagedListAdapter<HashTag, *> = SearchHashtagsAdapter(this)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance() = SearchHashtagsFragment()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,428 @@
|
||||||
|
/* Copyright 2019 Joel Pyska
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.components.search.fragments
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.preference.PreferenceManager
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.URLSpan
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import androidx.core.app.ActivityOptionsCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.paging.PagedList
|
||||||
|
import androidx.paging.PagedListAdapter
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.keylesspalace.tusky.*
|
||||||
|
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||||
|
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||||
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||||
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
|
import com.keylesspalace.tusky.util.NetworkState
|
||||||
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import kotlinx.android.synthetic.main.fragment_search.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
|
||||||
|
|
||||||
|
override val networkStateRefresh: LiveData<NetworkState>
|
||||||
|
get() = viewModel.networkStateStatusRefresh
|
||||||
|
override val networkState: LiveData<NetworkState>
|
||||||
|
get() = viewModel.networkStateStatus
|
||||||
|
override val data: LiveData<PagedList<Pair<Status, StatusViewData.Concrete>>>
|
||||||
|
get() = viewModel.statuses
|
||||||
|
|
||||||
|
override fun createAdapter(): PagedListAdapter<Pair<Status, StatusViewData.Concrete>, *> {
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context)
|
||||||
|
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
|
||||||
|
val showBotOverlay = preferences.getBoolean("showBotOverlay", true)
|
||||||
|
val animateAvatar = preferences.getBoolean("animateGifAvatars", false)
|
||||||
|
|
||||||
|
searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||||
|
searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context)
|
||||||
|
return SearchStatusesAdapter(useAbsoluteTime, viewModel.mediaPreviewEnabled, showBotOverlay, animateAvatar, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||||
|
viewModel.contentHiddenChange(it, isShowing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReply(position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status ->
|
||||||
|
reply(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let { status ->
|
||||||
|
viewModel.favorite(status, favourite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMore(view: View, position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let {
|
||||||
|
more(it, view, position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.actionableStatus?.let { actionable ->
|
||||||
|
when (actionable.attachments[attachmentIndex].type) {
|
||||||
|
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE -> {
|
||||||
|
val attachments = AttachmentViewData.list(actionable)
|
||||||
|
val intent = ViewMediaActivity.newIntent(context, attachments,
|
||||||
|
attachmentIndex)
|
||||||
|
if (view != null) {
|
||||||
|
val url = actionable.attachments[attachmentIndex].url
|
||||||
|
ViewCompat.setTransitionName(view, url)
|
||||||
|
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(),
|
||||||
|
view, url)
|
||||||
|
startActivity(intent, options.toBundle())
|
||||||
|
} else {
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Attachment.Type.UNKNOWN -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewThread(position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status ->
|
||||||
|
val actionableStatus = status.actionableStatus
|
||||||
|
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpenReblog(position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.first?.let { status ->
|
||||||
|
bottomSheetActivity?.viewAccount(status.account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||||
|
viewModel.expandedChange(it, expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadMore(position: Int) {
|
||||||
|
//Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||||
|
viewModel.collapsedChange(it, isCollapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||||
|
viewModel.voteInPoll(it, choices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeItem(position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let {
|
||||||
|
viewModel.removeItem(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReblog(reblog: Boolean, position: Int) {
|
||||||
|
(adapter as? SearchStatusesAdapter)?.getItem(position)?.let { status ->
|
||||||
|
viewModel.reblog(status, reblog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance() = SearchStatusesFragment()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reply(status: Status) {
|
||||||
|
val inReplyToId = status.actionableId
|
||||||
|
val actionableStatus = status.actionableStatus
|
||||||
|
val replyVisibility = actionableStatus.visibility
|
||||||
|
val contentWarning = actionableStatus.spoilerText
|
||||||
|
val mentions = actionableStatus.mentions
|
||||||
|
val mentionedUsernames = LinkedHashSet<String>()
|
||||||
|
mentionedUsernames.add(actionableStatus.account.username)
|
||||||
|
val loggedInUsername = viewModel.activeAccount?.username
|
||||||
|
for ((_, _, username) in mentions) {
|
||||||
|
mentionedUsernames.add(username)
|
||||||
|
}
|
||||||
|
mentionedUsernames.remove(loggedInUsername)
|
||||||
|
val intent = ComposeActivity.IntentBuilder()
|
||||||
|
.inReplyToId(inReplyToId)
|
||||||
|
.replyVisibility(replyVisibility)
|
||||||
|
.contentWarning(contentWarning)
|
||||||
|
.mentionedUsernames(mentionedUsernames)
|
||||||
|
.replyingStatusAuthor(actionableStatus.account.localUsername)
|
||||||
|
.replyingStatusContent(actionableStatus.content.toString())
|
||||||
|
.build(context)
|
||||||
|
requireActivity().startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun more(status: Status, view: View, position: Int) {
|
||||||
|
val id = status.actionableId
|
||||||
|
val accountId = status.actionableStatus.account.id
|
||||||
|
val accountUsername = status.actionableStatus.account.username
|
||||||
|
val content = status.actionableStatus.content
|
||||||
|
val statusUrl = status.actionableStatus.url
|
||||||
|
val accounts = viewModel.getAllAccountsOrderedByActive()
|
||||||
|
var openAsTitle: String? = null
|
||||||
|
|
||||||
|
val loggedInAccountId = viewModel.activeAccount?.accountId
|
||||||
|
|
||||||
|
val popup = PopupMenu(view.context, view)
|
||||||
|
// Give a different menu depending on whether this is the user's own toot or not.
|
||||||
|
if (loggedInAccountId == null || loggedInAccountId != accountId) {
|
||||||
|
popup.inflate(R.menu.status_more)
|
||||||
|
val menu = popup.menu
|
||||||
|
menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
popup.inflate(R.menu.status_more_for_user)
|
||||||
|
val menu = popup.menu
|
||||||
|
menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank()
|
||||||
|
when (status.visibility) {
|
||||||
|
Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> {
|
||||||
|
val textId = getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action)
|
||||||
|
menu.add(0, R.id.pin, 1, textId)
|
||||||
|
}
|
||||||
|
Status.Visibility.PRIVATE -> {
|
||||||
|
var reblogged = status.reblogged
|
||||||
|
if (status.reblog != null) reblogged = status.reblog.reblogged
|
||||||
|
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
|
||||||
|
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
|
||||||
|
}
|
||||||
|
Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> {
|
||||||
|
} //Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val menu = popup.menu
|
||||||
|
val openAsItem = menu.findItem(R.id.status_open_as)
|
||||||
|
when (accounts.size) {
|
||||||
|
0, 1 -> openAsItem.isVisible = false
|
||||||
|
2 -> for (account in accounts) {
|
||||||
|
if (account !== viewModel.activeAccount) {
|
||||||
|
openAsTitle = String.format(getString(R.string.action_open_as), account.fullName)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> openAsTitle = String.format(getString(R.string.action_open_as), "…")
|
||||||
|
}
|
||||||
|
openAsItem.title = openAsTitle
|
||||||
|
|
||||||
|
popup.setOnMenuItemClickListener { item ->
|
||||||
|
when (item.itemId) {
|
||||||
|
R.id.status_share_content -> {
|
||||||
|
var statusToShare: Status? = status
|
||||||
|
if (statusToShare!!.reblog != null) statusToShare = statusToShare.reblog
|
||||||
|
|
||||||
|
val sendIntent = Intent()
|
||||||
|
sendIntent.action = Intent.ACTION_SEND
|
||||||
|
|
||||||
|
val stringToShare = statusToShare!!.account.username +
|
||||||
|
" - " +
|
||||||
|
statusToShare.content.toString()
|
||||||
|
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare)
|
||||||
|
sendIntent.type = "text/plain"
|
||||||
|
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_content_to)))
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_share_link -> {
|
||||||
|
val sendIntent = Intent()
|
||||||
|
sendIntent.action = Intent.ACTION_SEND
|
||||||
|
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl)
|
||||||
|
sendIntent.type = "text/plain"
|
||||||
|
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_link_to)))
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_copy_link -> {
|
||||||
|
val clipboard = activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = ClipData.newPlainText(null, statusUrl)
|
||||||
|
clipboard.primaryClip = clip
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_open_as -> {
|
||||||
|
showOpenAsDialog(statusUrl!!, item.title)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_download_media -> {
|
||||||
|
requestDownloadAllMedia(status)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_mute -> {
|
||||||
|
viewModel.muteAcount(accountId)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_block -> {
|
||||||
|
viewModel.blockAccount(accountId)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_report -> {
|
||||||
|
openReportPage(accountId, accountUsername, id, content)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_unreblog_private -> {
|
||||||
|
onReblog(false, position)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_reblog_private -> {
|
||||||
|
onReblog(true, position)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_delete -> {
|
||||||
|
showConfirmDeleteDialog(id, position)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.status_delete_and_redraft -> {
|
||||||
|
showConfirmEditDialog(id, position, status)
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
R.id.pin -> {
|
||||||
|
viewModel.pinAccount(status, !status.isPinned())
|
||||||
|
return@setOnMenuItemClickListener true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
popup.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) {
|
||||||
|
bottomSheetActivity?.showAccountChooserDialog(dialogTitle, false, object : AccountSelectionListener {
|
||||||
|
override fun onAccountSelected(account: AccountEntity) {
|
||||||
|
openAsAccount(statusUrl, account)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAsAccount(statusUrl: String, account: AccountEntity) {
|
||||||
|
viewModel.activeAccount = account
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
intent.putExtra(MainActivity.STATUS_URL, statusUrl)
|
||||||
|
startActivity(intent)
|
||||||
|
(activity as BaseActivity).finishWithoutSlideOutAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadAllMedia(status: Status) {
|
||||||
|
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
|
||||||
|
for ((_, url) in status.attachments) {
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
val filename = uri.lastPathSegment
|
||||||
|
|
||||||
|
val downloadManager = activity!!.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
val request = DownloadManager.Request(uri)
|
||||||
|
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
|
||||||
|
downloadManager.enqueue(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestDownloadAllMedia(status: Status) {
|
||||||
|
val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
(activity as BaseActivity).requestPermissions(permissions) { _, grantResults ->
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
downloadAllMedia(status)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, R.string.error_media_download_permission, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReportPage(accountId: String, accountUsername: String, statusId: String,
|
||||||
|
statusContent: Spanned) {
|
||||||
|
startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId, statusContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showConfirmDeleteDialog(id: String, position: Int) {
|
||||||
|
context?.let {
|
||||||
|
AlertDialog.Builder(it)
|
||||||
|
.setMessage(R.string.dialog_delete_toot_warning)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
viewModel.deleteStatus(id)
|
||||||
|
removeItem(position)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
|
||||||
|
activity?.let {
|
||||||
|
AlertDialog.Builder(it)
|
||||||
|
.setMessage(R.string.dialog_redraft_toot_warning)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
viewModel.deleteStatus(id)
|
||||||
|
removeItem(position)
|
||||||
|
|
||||||
|
val intent = ComposeActivity.IntentBuilder()
|
||||||
|
.tootText(getEditableText(status.content, status.mentions))
|
||||||
|
.inReplyToId(status.inReplyToId)
|
||||||
|
.visibility(status.visibility)
|
||||||
|
.contentWarning(status.spoilerText)
|
||||||
|
.mediaAttachments(status.attachments)
|
||||||
|
.sensitive(status.sensitive)
|
||||||
|
.build(context)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEditableText(content: Spanned, mentions: Array<Status.Mention>): String {
|
||||||
|
val builder = SpannableStringBuilder(content)
|
||||||
|
for (span in content.getSpans(0, content.length, URLSpan::class.java)) {
|
||||||
|
val url = span.url
|
||||||
|
for ((_, url1, username) in mentions) {
|
||||||
|
if (url == url1) {
|
||||||
|
val start = builder.getSpanStart(span)
|
||||||
|
val end = builder.getSpanEnd(span)
|
||||||
|
builder.replace(start, end, "@$username")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di
|
||||||
import com.keylesspalace.tusky.*
|
import com.keylesspalace.tusky.*
|
||||||
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
||||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,9 @@ import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragme
|
||||||
import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment
|
import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment
|
||||||
import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment
|
import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment
|
||||||
import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment
|
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.Module
|
||||||
import dagger.android.ContributesAndroidInjector
|
import dagger.android.ContributesAndroidInjector
|
||||||
|
|
||||||
|
@ -50,7 +53,7 @@ abstract class FragmentBuildersModule {
|
||||||
abstract fun notificationsFragment(): NotificationsFragment
|
abstract fun notificationsFragment(): NotificationsFragment
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
abstract fun searchFragment(): SearchFragment
|
abstract fun searchFragment(): SearchStatusesFragment
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment
|
abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment
|
||||||
|
@ -75,4 +78,11 @@ abstract class FragmentBuildersModule {
|
||||||
|
|
||||||
@ContributesAndroidInjector
|
@ContributesAndroidInjector
|
||||||
abstract fun instanceListFragment(): InstanceListFragment
|
abstract fun instanceListFragment(): InstanceListFragment
|
||||||
|
|
||||||
|
@ContributesAndroidInjector
|
||||||
|
abstract fun searchAccountFragment(): SearchAccountsFragment
|
||||||
|
|
||||||
|
@ContributesAndroidInjector
|
||||||
|
abstract fun searchHashtagsFragment(): SearchHashtagsFragment
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
|
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
|
||||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||||
|
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||||
import com.keylesspalace.tusky.viewmodel.*
|
import com.keylesspalace.tusky.viewmodel.*
|
||||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
|
@ -65,5 +66,10 @@ abstract class ViewModelModule {
|
||||||
@ViewModelKey(ReportViewModel::class)
|
@ViewModelKey(ReportViewModel::class)
|
||||||
internal abstract fun reportViewModel(viewModel: ReportViewModel): ViewModel
|
internal abstract fun reportViewModel(viewModel: ReportViewModel): ViewModel
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ViewModelKey(SearchViewModel::class)
|
||||||
|
internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel
|
||||||
|
|
||||||
//Add more ViewModels here
|
//Add more ViewModels here
|
||||||
}
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
|
data class HashTag(val name: String)
|
14
app/src/main/java/com/keylesspalace/tusky/entity/History.kt
Normal file
14
app/src/main/java/com/keylesspalace/tusky/entity/History.kt
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class History(
|
||||||
|
@field:SerializedName("day")
|
||||||
|
val day: String,
|
||||||
|
|
||||||
|
@field:SerializedName("uses")
|
||||||
|
val uses: Int,
|
||||||
|
|
||||||
|
@field:SerializedName("accounts")
|
||||||
|
val accounts: Int
|
||||||
|
)
|
|
@ -0,0 +1,22 @@
|
||||||
|
/* Copyright 2017 Andrew Dawson
|
||||||
|
*
|
||||||
|
* This file is a part of Tusky.
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||||
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||||
|
* Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
|
data class SearchResults2 (
|
||||||
|
val accounts: List<Account>,
|
||||||
|
val statuses: List<Status>,
|
||||||
|
val hashtags: List<HashTag>
|
||||||
|
)
|
|
@ -1,275 +0,0 @@
|
||||||
/* Copyright 2018 Conny Duck
|
|
||||||
*
|
|
||||||
* This file is a part of Tusky.
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
||||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
||||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
||||||
* Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
||||||
* see <http://www.gnu.org/licenses>. */
|
|
||||||
|
|
||||||
package com.keylesspalace.tusky.fragment
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.preference.PreferenceManager
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import com.keylesspalace.tusky.AccountActivity
|
|
||||||
import com.keylesspalace.tusky.R
|
|
||||||
import com.keylesspalace.tusky.ViewTagActivity
|
|
||||||
import com.keylesspalace.tusky.adapter.SearchResultsAdapter
|
|
||||||
import com.keylesspalace.tusky.entity.SearchResults
|
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
|
||||||
import com.keylesspalace.tusky.util.ViewDataUtils
|
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
|
||||||
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
|
|
||||||
import com.uber.autodispose.autoDisposable
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
|
||||||
import kotlinx.android.synthetic.main.fragment_search.*
|
|
||||||
import retrofit2.Call
|
|
||||||
import retrofit2.Callback
|
|
||||||
import retrofit2.Response
|
|
||||||
|
|
||||||
class SearchFragment : SFragment(), StatusActionListener {
|
|
||||||
|
|
||||||
private lateinit var searchAdapter: SearchResultsAdapter
|
|
||||||
|
|
||||||
private var alwaysShowSensitiveMedia = false
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
|
||||||
return inflater.inflate(R.layout.fragment_search, container, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
|
||||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
|
|
||||||
val showBotOverlay = preferences.getBoolean("showBotOverlay", true)
|
|
||||||
val animateAvatar = preferences.getBoolean("animateGifAvatars", false)
|
|
||||||
|
|
||||||
val account = accountManager.activeAccount
|
|
||||||
alwaysShowSensitiveMedia = account?.alwaysShowSensitiveMedia ?: false
|
|
||||||
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true
|
|
||||||
|
|
||||||
searchRecyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
|
||||||
searchRecyclerView.layoutManager = LinearLayoutManager(view.context)
|
|
||||||
searchAdapter = SearchResultsAdapter(
|
|
||||||
this,
|
|
||||||
this,
|
|
||||||
mediaPreviewEnabled,
|
|
||||||
alwaysShowSensitiveMedia,
|
|
||||||
useAbsoluteTime,
|
|
||||||
showBotOverlay,
|
|
||||||
animateAvatar
|
|
||||||
)
|
|
||||||
searchRecyclerView.adapter = searchAdapter
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search(query: String) {
|
|
||||||
clearResults()
|
|
||||||
val callback = object : Callback<SearchResults> {
|
|
||||||
override fun onResponse(call: Call<SearchResults>, response: Response<SearchResults>) {
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
val results = response.body()
|
|
||||||
if (results != null && (results.accounts.isNotEmpty() || results.statuses.isNotEmpty() || results.hashtags.isNotEmpty())) {
|
|
||||||
searchAdapter.updateSearchResults(results)
|
|
||||||
hideFeedback()
|
|
||||||
} else {
|
|
||||||
displayNoResults()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSearchFailure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(call: Call<SearchResults>, t: Throwable) {
|
|
||||||
onSearchFailure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mastodonApi.search(query, true)
|
|
||||||
.enqueue(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSearchFailure() {
|
|
||||||
displayNoResults()
|
|
||||||
Log.e(TAG, "Search request failed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearResults() {
|
|
||||||
searchAdapter.updateSearchResults(null)
|
|
||||||
searchProgressBar.visibility = View.VISIBLE
|
|
||||||
searchNoResultsText.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayNoResults() {
|
|
||||||
if (isAdded) {
|
|
||||||
searchProgressBar.visibility = View.GONE
|
|
||||||
searchNoResultsText.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideFeedback() {
|
|
||||||
if (isAdded) {
|
|
||||||
searchProgressBar.visibility = View.GONE
|
|
||||||
searchNoResultsText.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeItem(position: Int) {
|
|
||||||
searchAdapter.removeStatusAtPosition(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReply(position: Int) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
super.reply(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReblog(reblog: Boolean, position: Int) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
timelineCases.reblog(status, reblog)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
|
|
||||||
.subscribe({
|
|
||||||
status.reblogged = reblog
|
|
||||||
searchAdapter.updateStatusAtPosition(
|
|
||||||
ViewDataUtils.statusToViewData(
|
|
||||||
status,
|
|
||||||
alwaysShowSensitiveMedia
|
|
||||||
),
|
|
||||||
position,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}, { t -> Log.d(TAG, "Failed to reblog status " + status.id, t) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
timelineCases.favourite(status, favourite)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
|
|
||||||
.subscribe({
|
|
||||||
status.favourited = favourite
|
|
||||||
searchAdapter.updateStatusAtPosition(
|
|
||||||
ViewDataUtils.statusToViewData(
|
|
||||||
status,
|
|
||||||
alwaysShowSensitiveMedia
|
|
||||||
),
|
|
||||||
position,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}, { t -> Log.d(TAG, "Failed to favourite status " + status.id, t) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
more(status, view, position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position) ?: return
|
|
||||||
viewMedia(attachmentIndex, status, view)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewThread(position: Int) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
viewThread(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOpenReblog(position: Int) {
|
|
||||||
// there are no reblogs in search results
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
|
||||||
val status = searchAdapter.getConcreteStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
val newStatus = StatusViewData.Builder(status)
|
|
||||||
.setIsExpanded(expanded).createStatusViewData()
|
|
||||||
searchAdapter.updateStatusAtPosition(newStatus, position, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
|
||||||
val status = searchAdapter.getConcreteStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
val newStatus = StatusViewData.Builder(status)
|
|
||||||
.setIsShowingSensitiveContent(isShowing).createStatusViewData()
|
|
||||||
searchAdapter.updateStatusAtPosition(newStatus, position, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadMore(position: Int) {
|
|
||||||
// not needed here, search is not paginated
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
|
||||||
// TODO: No out-of-bounds check in getConcreteStatusAtPosition
|
|
||||||
val status = searchAdapter.getConcreteStatusAtPosition(position)
|
|
||||||
if (status == null) {
|
|
||||||
Log.e(TAG, String.format("Tried to access status but got null at position: %d", position))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val updatedStatus = StatusViewData.Builder(status)
|
|
||||||
.setCollapsed(isCollapsed)
|
|
||||||
.createStatusViewData()
|
|
||||||
searchAdapter.updateStatusAtPosition(updatedStatus, position, false)
|
|
||||||
searchRecyclerView.post { searchAdapter.notifyItemChanged(position, updatedStatus) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewAccount(id: String) {
|
|
||||||
val intent = AccountActivity.getIntent(requireContext(), id)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewTag(tag: String) {
|
|
||||||
val intent = Intent(context, ViewTagActivity::class.java)
|
|
||||||
intent.putExtra("hashtag", tag)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
|
||||||
val status = searchAdapter.getStatusAtPosition(position)
|
|
||||||
if (status != null) {
|
|
||||||
timelineCases.voteInPoll(status, choices)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
|
|
||||||
.subscribe({poll ->
|
|
||||||
val viewData = ViewDataUtils.statusToViewData(
|
|
||||||
status,
|
|
||||||
alwaysShowSensitiveMedia
|
|
||||||
)
|
|
||||||
val newViewData = StatusViewData.Builder(viewData)
|
|
||||||
.setPoll(poll)
|
|
||||||
.createStatusViewData()
|
|
||||||
searchAdapter.updateStatusAtPosition(newViewData, position, true)
|
|
||||||
|
|
||||||
}, { t -> Log.d(TAG, "Failed to vote in poll " + status.id, t) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "SearchFragment"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.Notification;
|
||||||
import com.keylesspalace.tusky.entity.Poll;
|
import com.keylesspalace.tusky.entity.Poll;
|
||||||
import com.keylesspalace.tusky.entity.Relationship;
|
import com.keylesspalace.tusky.entity.Relationship;
|
||||||
import com.keylesspalace.tusky.entity.SearchResults;
|
import com.keylesspalace.tusky.entity.SearchResults;
|
||||||
|
import com.keylesspalace.tusky.entity.SearchResults2;
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
import com.keylesspalace.tusky.entity.StatusContext;
|
import com.keylesspalace.tusky.entity.StatusContext;
|
||||||
|
|
||||||
|
@ -437,4 +438,7 @@ public interface MastodonApi {
|
||||||
@GET("api/v1/statuses/{id}")
|
@GET("api/v1/statuses/{id}")
|
||||||
Single<Status> statusObservable(@Path("id") String statusId);
|
Single<Status> statusObservable(@Path("id") String statusId);
|
||||||
|
|
||||||
|
@GET("api/v2/search")
|
||||||
|
Single<SearchResults2> searchObservable(@Query("type") String type, @Query("q") String q, @Query("resolve") Boolean resolve, @Query("limit") Integer limit, @Query("offset") Integer offset, @Query("following") Boolean following);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2017 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.keylesspalace.tusky.util
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.paging.PagedList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
|
||||||
|
*/
|
||||||
|
data class MultiListing(
|
||||||
|
val pagedLists: List<LiveData<PagedList<Any>>>,
|
||||||
|
// represents the network request status to show to the user
|
||||||
|
val networkState: LiveData<NetworkState>,
|
||||||
|
// represents the refresh status to show to the user. Separate from networkState, this
|
||||||
|
// value is importantly only when refresh is requested.
|
||||||
|
val refreshState: LiveData<NetworkState>,
|
||||||
|
// refreshes the whole data and fetches it from scratch.
|
||||||
|
val refresh: () -> Unit,
|
||||||
|
// retries any failed requests.
|
||||||
|
val retry: () -> Unit)
|
|
@ -19,5 +19,12 @@ data class AttachmentViewData(
|
||||||
AttachmentViewData(it, actionable.id, actionable.url!!)
|
AttachmentViewData(it, actionable.id, actionable.url!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun list(attachments: List<Attachment>): List<AttachmentViewData> {
|
||||||
|
return attachments.map {
|
||||||
|
AttachmentViewData(it, it.id, it.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context="com.keylesspalace.tusky.SearchActivity">
|
tools:context="com.keylesspalace.tusky.components.search.SearchActivity">
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -19,12 +19,21 @@
|
||||||
android:layout_height="?attr/actionBarSize"
|
android:layout_height="?attr/actionBarSize"
|
||||||
android:background="?attr/toolbar_background_color"
|
android:background="?attr/toolbar_background_color"
|
||||||
app:contentInsetStartWithNavigation="0dp"
|
app:contentInsetStartWithNavigation="0dp"
|
||||||
|
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||||
app:navigationIcon="?attr/homeAsUpIndicator" />
|
app:navigationIcon="?attr/homeAsUpIndicator" />
|
||||||
|
<com.google.android.material.tabs.TabLayout
|
||||||
|
android:id="@+id/tabs"
|
||||||
|
style="@style/TuskyTabAppearance"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/toolbar_background_color"
|
||||||
|
app:tabGravity="fill"
|
||||||
|
app:tabMode="fixed"
|
||||||
|
app:tabTextAppearance="@style/TuskyTabAppearance"/>
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.viewpager.widget.ViewPager
|
||||||
android:id="@+id/fragment_container"
|
android:id="@+id/pages"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||||
|
|
|
@ -1,22 +1,25 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:id="@+id/layoutRoot"
|
||||||
|
android:layout_width="@dimen/timeline_width"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?attr/tab_page_margin_drawable">
|
android:background="?attr/tab_page_margin_drawable">
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:layout_width="@dimen/timeline_width"
|
android:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_height="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_gravity="center">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/searchRecyclerView"
|
android:id="@+id/searchRecyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
tools:listitem="@layout/item_account"
|
android:background="?attr/window_background"
|
||||||
android:background="?attr/window_background" />
|
tools:listitem="@layout/item_account"/>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
android:id="@+id/searchProgressBar"
|
android:id="@+id/searchProgressBar"
|
||||||
|
@ -33,6 +36,15 @@
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:text="@string/search_no_results"
|
android:text="@string/search_no_results"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBarBottom"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/account_container"
|
android:id="@+id/account_container"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
android:layout_height="72dp"
|
android:layout_height="72dp"
|
||||||
android:paddingStart="16dp"
|
android:paddingStart="16dp"
|
||||||
android:paddingEnd="16dp">
|
android:paddingEnd="16dp">
|
||||||
|
|
|
@ -4,4 +4,5 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
android:textSize="?attr/status_text_medium" />
|
android:textSize="?attr/status_text_medium" />
|
||||||
|
|
|
@ -513,6 +513,8 @@
|
||||||
<string name="failed_fetch_statuses">Failed to fetch statuses</string>
|
<string name="failed_fetch_statuses">Failed to fetch statuses</string>
|
||||||
<string name="report_description_1">The report will be sent to your server moderator. You can provide an explanation of why you are reporting this account below:</string>
|
<string name="report_description_1">The report will be sent to your server moderator. You can provide an explanation of why you are reporting this account below:</string>
|
||||||
<string name="report_description_remote_instance">The account is from another server. Send an anonymized copy of the report there as well?</string>
|
<string name="report_description_remote_instance">The account is from another server. Send an anonymized copy of the report there as well?</string>
|
||||||
|
<string name="title_accounts">Accounts</string>
|
||||||
|
<string name="failed_search">Failed to search</string>
|
||||||
|
|
||||||
<string name="pref_title_show_notifications_filter">Show Notifications filter</string>
|
<string name="pref_title_show_notifications_filter">Show Notifications filter</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
Loading…
Reference in a new issue