Add EventHub, add fav, reblog events, improve timelines (#562)
* Add AppStore, add fav, reblog events * Add events, add handling to Timeline * Add event handling to Notifications * Mostly finish events * Fix unsubscribing * Cleanup timeline * Fix newStatusEvent in thread, fix deleteEvent * Insert new toots only in specific timelines * Add missing else * Rename AppStore to EventHub * Fix tests * Use DiffUtils for timeline * Fix empty timeline bug. Improve loading placeholder * Fix AsyncListDiff, loading indicator, "load more" * Timeline fixes & improvements. Fix infinite loading. Remove spinner correctly. Don't refresh timeline without need.
This commit is contained in:
parent
3a8d96346b
commit
3756a1fd20
31 changed files with 1064 additions and 542 deletions
|
@ -96,4 +96,9 @@ dependencies {
|
|||
})
|
||||
|
||||
debugImplementation 'im.dino:dbinspector:3.4.1@aar'
|
||||
|
||||
implementation 'io.reactivex.rxjava2:rxjava:2.1.12'
|
||||
implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
|
||||
implementation 'com.uber.autodispose:autodispose-android-archcomponents:0.7.0'
|
||||
implementation 'com.uber.autodispose:autodispose-kotlin:0.7.0'
|
||||
}
|
||||
|
|
|
@ -47,13 +47,16 @@ import android.widget.Button;
|
|||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent;
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent;
|
||||
import com.keylesspalace.tusky.appstore.UnfollowEvent;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.entity.Account;
|
||||
import com.keylesspalace.tusky.entity.Relationship;
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||
import com.keylesspalace.tusky.pager.AccountPagerAdapter;
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver;
|
||||
import com.keylesspalace.tusky.util.Assert;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
@ -86,6 +89,8 @@ public final class AccountActivity extends BottomSheetActivity implements Action
|
|||
|
||||
@Inject
|
||||
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
|
||||
@Inject
|
||||
public EventHub appstore;
|
||||
|
||||
private String accountId;
|
||||
private FollowState followState;
|
||||
|
@ -524,7 +529,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action
|
|||
Snackbar.LENGTH_LONG).show();
|
||||
} else {
|
||||
followState = FollowState.NOT_FOLLOWING;
|
||||
broadcast(TimelineReceiver.Types.UNFOLLOW_ACCOUNT, id);
|
||||
appstore.dispatch(new UnfollowEvent(id));
|
||||
}
|
||||
updateButtons();
|
||||
} else {
|
||||
|
@ -581,7 +586,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action
|
|||
@NonNull Response<Relationship> response) {
|
||||
Relationship relationship = response.body();
|
||||
if (response.isSuccessful() && relationship != null) {
|
||||
broadcast(TimelineReceiver.Types.BLOCK_ACCOUNT, id);
|
||||
appstore.dispatch(new BlockEvent(id));
|
||||
blocking = relationship.getBlocking();
|
||||
updateButtons();
|
||||
} else {
|
||||
|
@ -615,7 +620,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action
|
|||
@NonNull Response<Relationship> response) {
|
||||
Relationship relationship = response.body();
|
||||
if (response.isSuccessful() && relationship != null) {
|
||||
broadcast(TimelineReceiver.Types.MUTE_ACCOUNT, id);
|
||||
appstore.dispatch(new MuteEvent(id));
|
||||
muting = relationship.getMuting();
|
||||
updateButtons();
|
||||
} else {
|
||||
|
|
|
@ -85,6 +85,8 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter;
|
|||
import com.keylesspalace.tusky.adapter.MentionAutoCompleteAdapter;
|
||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.InstanceEntity;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.entity.Account;
|
||||
|
@ -169,6 +171,8 @@ public final class ComposeActivity
|
|||
|
||||
@Inject
|
||||
public MastodonApi mastodonApi;
|
||||
@Inject
|
||||
public AppDatabase database;
|
||||
|
||||
private TextView replyTextView;
|
||||
private TextView replyContentTextView;
|
||||
|
@ -230,7 +234,7 @@ public final class ComposeActivity
|
|||
emojiView = findViewById(R.id.emojiView);
|
||||
emojiList = Collections.emptyList();
|
||||
|
||||
saveTootHelper = new SaveTootHelper(TuskyApplication.getDB().tootDao(), this);
|
||||
saveTootHelper = new SaveTootHelper(database.tootDao(), this);
|
||||
|
||||
// Setup the toolbar.
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
|
@ -1454,7 +1458,8 @@ public final class ComposeActivity
|
|||
}
|
||||
|
||||
private void loadCachedInstanceMetadata(@NotNull AccountEntity activeAccount) {
|
||||
InstanceEntity instanceEntity = TuskyApplication.getDB().instanceDao().loadMetadataForInstance(activeAccount.getDomain());
|
||||
InstanceEntity instanceEntity = database.instanceDao()
|
||||
.loadMetadataForInstance(activeAccount.getDomain());
|
||||
|
||||
if(instanceEntity != null) {
|
||||
Integer max = instanceEntity.getMaximumTootCharacters();
|
||||
|
@ -1474,7 +1479,7 @@ public final class ComposeActivity
|
|||
|
||||
private void cacheInstanceMetadata(@NotNull AccountEntity activeAccount) {
|
||||
InstanceEntity instanceEntity = new InstanceEntity(activeAccount.getDomain(), emojiList, maximumTootCharacters);
|
||||
TuskyApplication.getDB().instanceDao().insertOrReplace(instanceEntity);
|
||||
database.instanceDao().insertOrReplace(instanceEntity);
|
||||
}
|
||||
|
||||
// Accessors for testing, hence package scope
|
||||
|
|
|
@ -70,7 +70,7 @@ import retrofit2.Call;
|
|||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class MainActivity extends BottomSheetActivity implements ActionButtonActivity,
|
||||
public final class MainActivity extends BottomSheetActivity implements ActionButtonActivity,
|
||||
HasSupportFragmentInjector {
|
||||
private static final String TAG = "MainActivity"; // logging tag
|
||||
private static final long DRAWER_ITEM_ADD_ACCOUNT = -13;
|
||||
|
|
|
@ -15,15 +15,12 @@
|
|||
|
||||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.arch.lifecycle.Lifecycle;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.support.v7.app.ActionBar;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
|
@ -34,9 +31,12 @@ import android.view.View;
|
|||
import android.widget.TextView;
|
||||
|
||||
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
|
@ -44,10 +44,15 @@ import java.lang.ref.WeakReference;
|
|||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction {
|
||||
import javax.inject.Inject;
|
||||
|
||||
// dao
|
||||
private static TootDao tootDao = TuskyApplication.getDB().tootDao();
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
|
||||
import static com.uber.autodispose.AutoDispose.autoDisposable;
|
||||
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||
|
||||
public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction,
|
||||
Injectable {
|
||||
|
||||
private SaveTootHelper saveTootHelper;
|
||||
|
||||
|
@ -56,28 +61,25 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
|
|||
private TextView noContent;
|
||||
|
||||
private List<TootEntity> toots = new ArrayList<>();
|
||||
@Nullable private AsyncTask<?, ?, ?> asyncTask;
|
||||
@Nullable
|
||||
private AsyncTask<?, ?, ?> asyncTask;
|
||||
|
||||
private BroadcastReceiver broadcastReceiver;
|
||||
@Inject
|
||||
EventHub eventHub;
|
||||
@Inject
|
||||
AppDatabase database;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
saveTootHelper = new SaveTootHelper(tootDao, this);
|
||||
saveTootHelper = new SaveTootHelper(database.tootDao(), this);
|
||||
|
||||
broadcastReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
fetchToots();
|
||||
}
|
||||
};
|
||||
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(TimelineReceiver.Types.STATUS_COMPOSED);
|
||||
|
||||
LocalBroadcastManager.getInstance(this)
|
||||
.registerReceiver(broadcastReceiver, intentFilter);
|
||||
eventHub.getEvents()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.ofType(StatusComposedEvent.class)
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe((__) -> this.fetchToots());
|
||||
|
||||
setContentView(R.layout.activity_saved_toot);
|
||||
|
||||
|
@ -117,12 +119,6 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
|
|||
if (asyncTask != null) asyncTask.cancel(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
|
@ -135,7 +131,7 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
|
|||
}
|
||||
|
||||
private void fetchToots() {
|
||||
asyncTask = new FetchPojosTask(this)
|
||||
asyncTask = new FetchPojosTask(this, database.tootDao())
|
||||
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
|
@ -178,9 +174,11 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
|
|||
static final class FetchPojosTask extends AsyncTask<Void, Void, List<TootEntity>> {
|
||||
|
||||
private final WeakReference<SavedTootActivity> activityRef;
|
||||
private final TootDao tootDao;
|
||||
|
||||
FetchPojosTask(SavedTootActivity activity) {
|
||||
FetchPojosTask(SavedTootActivity activity, TootDao tootDao) {
|
||||
this.activityRef = new WeakReference<>(activity);
|
||||
this.tootDao = tootDao;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -194,13 +192,12 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
|
|||
SavedTootActivity activity = activityRef.get();
|
||||
if (activity == null) return;
|
||||
|
||||
activity.toots.clear();
|
||||
activity.toots.addAll(pojos);
|
||||
|
||||
// set ui
|
||||
activity.setNoContent(pojos.size());
|
||||
List<TootEntity> toots = new ArrayList<>(pojos.size());
|
||||
toots.addAll(pojos);
|
||||
activity.adapter.setItems(toots);
|
||||
activity.adapter.setItems(activity.toots);
|
||||
activity.adapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,30 +18,35 @@ package com.keylesspalace.tusky.adapter;
|
|||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.ProgressBar;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
|
||||
public class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
||||
public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private Button loadMoreButton;
|
||||
|
||||
private ProgressBar progressBar;
|
||||
|
||||
PlaceholderViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
loadMoreButton = itemView.findViewById(R.id.button_load_more);
|
||||
|
||||
progressBar = itemView.findViewById(R.id.progress_bar);
|
||||
}
|
||||
|
||||
public void setup(boolean enabled, final StatusActionListener listener){
|
||||
public void setup(boolean enabled, final StatusActionListener listener) {
|
||||
this.setup(enabled, listener, false);
|
||||
}
|
||||
|
||||
public void setup(boolean enabled, final StatusActionListener listener, boolean progress) {
|
||||
loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE);
|
||||
progressBar.setVisibility(progress ? View.VISIBLE : View.GONE);
|
||||
|
||||
loadMoreButton.setEnabled(enabled);
|
||||
if(enabled) {
|
||||
loadMoreButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (enabled) {
|
||||
loadMoreButton.setOnClickListener(v -> {
|
||||
loadMoreButton.setEnabled(false);
|
||||
listener.onLoadMore(getAdapterPosition());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -118,6 +118,11 @@ public class ThreadAdapter extends RecyclerView.Adapter {
|
|||
notifyItemRangeInserted(end, statuses.size());
|
||||
}
|
||||
|
||||
public void removeItem(int position) {
|
||||
statuses.remove(position);
|
||||
notifyItemRemoved(position);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
statuses.clear();
|
||||
detailedStatusPosition = RecyclerView.NO_POSITION;
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
|
@ -25,29 +25,32 @@ import com.keylesspalace.tusky.R;
|
|||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
public final class TimelineAdapter extends RecyclerView.Adapter {
|
||||
|
||||
public interface AdapterDataSource<T> {
|
||||
int getItemCount();
|
||||
|
||||
T getItemAt(int pos);
|
||||
}
|
||||
|
||||
public class TimelineAdapter extends RecyclerView.Adapter {
|
||||
private static final int VIEW_TYPE_STATUS = 0;
|
||||
private static final int VIEW_TYPE_FOOTER = 1;
|
||||
private static final int VIEW_TYPE_PLACEHOLDER = 2;
|
||||
|
||||
private List<StatusViewData> statuses;
|
||||
private StatusActionListener statusListener;
|
||||
private FooterViewHolder.State footerState;
|
||||
private final AdapterDataSource<StatusViewData> dataSource;
|
||||
private final StatusActionListener statusListener;
|
||||
private boolean mediaPreviewEnabled;
|
||||
|
||||
public TimelineAdapter(StatusActionListener statusListener) {
|
||||
public TimelineAdapter(AdapterDataSource<StatusViewData> dataSource,
|
||||
StatusActionListener statusListener) {
|
||||
super();
|
||||
statuses = new ArrayList<>();
|
||||
this.dataSource = dataSource;
|
||||
this.statusListener = statusListener;
|
||||
footerState = FooterViewHolder.State.END;
|
||||
mediaPreviewEnabled = true;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
|
||||
switch (viewType) {
|
||||
default:
|
||||
case VIEW_TYPE_STATUS: {
|
||||
|
@ -55,11 +58,6 @@ public class TimelineAdapter extends RecyclerView.Adapter {
|
|||
.inflate(R.layout.item_status, viewGroup, false);
|
||||
return new StatusViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_FOOTER: {
|
||||
View view = LayoutInflater.from(viewGroup.getContext())
|
||||
.inflate(R.layout.item_footer, viewGroup, false);
|
||||
return new FooterViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_PLACEHOLDER: {
|
||||
View view = LayoutInflater.from(viewGroup.getContext())
|
||||
.inflate(R.layout.item_status_placeholder, viewGroup, false);
|
||||
|
@ -69,76 +67,39 @@ public class TimelineAdapter extends RecyclerView.Adapter {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
|
||||
if (position < statuses.size()) {
|
||||
StatusViewData status = statuses.get(position);
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
StatusViewData status = dataSource.getItemAt(position);
|
||||
if (status instanceof StatusViewData.Placeholder) {
|
||||
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
||||
holder.setup(!((StatusViewData.Placeholder) status).isLoading(), statusListener);
|
||||
holder.setup(!((StatusViewData.Placeholder) status).isLoading(),
|
||||
statusListener, ((StatusViewData.Placeholder) status).isLoading());
|
||||
} else {
|
||||
|
||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
holder.setupWithStatus((StatusViewData.Concrete) status,
|
||||
statusListener, mediaPreviewEnabled);
|
||||
}
|
||||
|
||||
} else {
|
||||
FooterViewHolder holder = (FooterViewHolder) viewHolder;
|
||||
holder.setState(footerState);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return statuses.size() + 1;
|
||||
return dataSource.getItemCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (position == statuses.size()) {
|
||||
return VIEW_TYPE_FOOTER;
|
||||
} else {
|
||||
if (statuses.get(position) instanceof StatusViewData.Placeholder) {
|
||||
if (dataSource.getItemAt(position) instanceof StatusViewData.Placeholder) {
|
||||
return VIEW_TYPE_PLACEHOLDER;
|
||||
} else {
|
||||
return VIEW_TYPE_STATUS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void update(@Nullable List<StatusViewData> newStatuses) {
|
||||
if (newStatuses == null || newStatuses.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
statuses.clear();
|
||||
statuses.addAll(newStatuses);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void addItems(List<StatusViewData> newStatuses) {
|
||||
statuses.addAll(newStatuses);
|
||||
notifyItemRangeInserted(statuses.size(), newStatuses.size());
|
||||
}
|
||||
|
||||
public void changeItem(int position, StatusViewData newData, boolean notifyAdapter) {
|
||||
statuses.set(position, newData);
|
||||
if (notifyAdapter) notifyItemChanged(position);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
statuses.clear();
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setFooterState(FooterViewHolder.State newFooterState) {
|
||||
FooterViewHolder.State oldValue = footerState;
|
||||
footerState = newFooterState;
|
||||
if (footerState != oldValue) {
|
||||
notifyItemChanged(statuses.size());
|
||||
}
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean enabled) {
|
||||
mediaPreviewEnabled = enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return dataSource.getItemAt(position).getViewDataId();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
|
||||
interface Event
|
||||
interface Dispatchable : Event
|
||||
|
||||
interface EventHub {
|
||||
val events: Observable<Event>
|
||||
fun dispatch(event: Dispatchable)
|
||||
}
|
||||
|
||||
object EventHubImpl : EventHub {
|
||||
|
||||
private val eventsSubject = PublishSubject.create<Event>()
|
||||
override val events: Observable<Event> = eventsSubject
|
||||
|
||||
override fun dispatch(event: Dispatchable) {
|
||||
eventsSubject.onNext(event)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
||||
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
|
||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable
|
||||
data class UnfollowEvent(val accountId: String) : Dispatchable
|
||||
data class BlockEvent(val accountId: String) : Dispatchable
|
||||
data class MuteEvent(val accountId: String) : Dispatchable
|
||||
data class StatusDeletedEvent(val statusId: String) : Dispatchable
|
||||
data class StatusComposedEvent(val status: Status) : Dispatchable
|
|
@ -22,7 +22,10 @@ import android.content.SharedPreferences
|
|||
import android.preference.PreferenceManager
|
||||
import android.support.v4.content.LocalBroadcastManager
|
||||
import com.keylesspalace.tusky.TuskyApplication
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.EventHubImpl
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.network.TimelineCasesImpl
|
||||
|
@ -55,8 +58,8 @@ class AppModule {
|
|||
|
||||
@Provides
|
||||
fun providesTimelineUseCases(api: MastodonApi,
|
||||
broadcastManager: LocalBroadcastManager): TimelineCases {
|
||||
return TimelineCasesImpl(api, broadcastManager)
|
||||
eventHub: EventHub): TimelineCases {
|
||||
return TimelineCasesImpl(api, eventHub)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
@ -64,4 +67,12 @@ class AppModule {
|
|||
fun providesAccountManager(app: TuskyApplication): AccountManager {
|
||||
return app.serviceLocator.get(AccountManager::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesEventHub(): EventHub = EventHubImpl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesDatabase(app: TuskyApplication): AppDatabase = TuskyApplication.getDB()
|
||||
}
|
|
@ -124,6 +124,29 @@ data class Status(
|
|||
|
||||
@SerializedName("username")
|
||||
var localUsername: String? = null
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Mention
|
||||
|
||||
if (id != other.id) return false
|
||||
if (url != other.url) return false
|
||||
if (username != other.username) return false
|
||||
if (localUsername != other.localUsername) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id?.hashCode() ?: 0
|
||||
result = 31 * result + (url?.hashCode() ?: 0)
|
||||
result = 31 * result + (username?.hashCode() ?: 0)
|
||||
result = 31 * result + (localUsername?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Application {
|
||||
|
|
|
@ -154,7 +154,7 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
|
|||
// Just use the basic scroll listener to load more accounts.
|
||||
scrollListener = new EndlessOnScrollListener(layoutManager) {
|
||||
@Override
|
||||
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
|
||||
public void onLoadMore(int totalItemsCount, RecyclerView view) {
|
||||
AccountListFragment.this.onLoadMore(view);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
package com.keylesspalace.tusky.fragment;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.Fragment;
|
||||
|
||||
|
@ -26,11 +28,21 @@ import retrofit2.Call;
|
|||
|
||||
public class BaseFragment extends Fragment {
|
||||
protected List<Call> callList;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
callList = new ArrayList<>();
|
||||
handler.post(this::onPostCreate);
|
||||
}
|
||||
|
||||
/**
|
||||
* For actions which should happen only once per lifecycle but after onCreate.
|
||||
* Example: subscribe for events in {@code onCreate()} but need dependencies to be injected
|
||||
*/
|
||||
public void onPostCreate() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -17,6 +17,7 @@ package com.keylesspalace.tusky.fragment;
|
|||
|
||||
import android.app.Activity;
|
||||
import android.arch.core.util.Function;
|
||||
import android.arch.lifecycle.Lifecycle;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
@ -26,30 +27,36 @@ import android.support.annotation.NonNull;
|
|||
import android.support.annotation.Nullable;
|
||||
import android.support.design.widget.FloatingActionButton;
|
||||
import android.support.design.widget.TabLayout;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.support.v4.util.Pair;
|
||||
import android.support.v4.widget.SwipeRefreshLayout;
|
||||
import android.support.v7.content.res.AppCompatResources;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.support.v7.widget.SimpleItemAnimator;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.adapter.FooterViewHolder;
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent;
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.network.TimelineCases;
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver;
|
||||
import com.keylesspalace.tusky.util.CollectionUtil;
|
||||
import com.keylesspalace.tusky.util.Either;
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink;
|
||||
|
@ -64,13 +71,18 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|||
import java.math.BigInteger;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
import static com.uber.autodispose.AutoDispose.autoDisposable;
|
||||
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||
|
||||
public class NotificationsFragment extends SFragment implements
|
||||
SwipeRefreshLayout.OnRefreshListener,
|
||||
StatusActionListener,
|
||||
|
@ -106,15 +118,19 @@ public class NotificationsFragment extends SFragment implements
|
|||
public TimelineCases timelineCases;
|
||||
@Inject
|
||||
AccountManager accountManager;
|
||||
@Inject
|
||||
EventHub eventHub;
|
||||
|
||||
private SwipeRefreshLayout swipeRefreshLayout;
|
||||
private LinearLayoutManager layoutManager;
|
||||
private RecyclerView recyclerView;
|
||||
private ProgressBar progressBar;
|
||||
private TextView nothingMessageView;
|
||||
|
||||
private LinearLayoutManager layoutManager;
|
||||
private EndlessOnScrollListener scrollListener;
|
||||
private NotificationsAdapter adapter;
|
||||
private TabLayout.OnTabSelectedListener onTabSelectedListener;
|
||||
private boolean hideFab;
|
||||
private TimelineReceiver timelineReceiver;
|
||||
private boolean topLoading;
|
||||
private int topFetches;
|
||||
private boolean bottomLoading;
|
||||
|
@ -158,11 +174,14 @@ public class NotificationsFragment extends SFragment implements
|
|||
@NonNull Context context = inflater.getContext(); // from inflater to silence warning
|
||||
// Setup the SwipeRefreshLayout.
|
||||
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
|
||||
recyclerView = rootView.findViewById(R.id.recycler_view);
|
||||
progressBar = rootView.findViewById(R.id.progress_bar);
|
||||
nothingMessageView = rootView.findViewById(R.id.nothing_message);
|
||||
|
||||
swipeRefreshLayout.setOnRefreshListener(this);
|
||||
swipeRefreshLayout.setColorSchemeResources(R.color.primary);
|
||||
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground));
|
||||
// Setup the RecyclerView.
|
||||
recyclerView = rootView.findViewById(R.id.recycler_view);
|
||||
recyclerView.setHasFixedSize(true);
|
||||
layoutManager = new LinearLayoutManager(context);
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
|
@ -181,10 +200,6 @@ public class NotificationsFragment extends SFragment implements
|
|||
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
timelineReceiver = new TimelineReceiver(this);
|
||||
LocalBroadcastManager.getInstance(context.getApplicationContext())
|
||||
.registerReceiver(timelineReceiver, TimelineReceiver.getFilter(null));
|
||||
|
||||
notifications.clear();
|
||||
topLoading = false;
|
||||
topFetches = 0;
|
||||
|
@ -193,9 +208,58 @@ public class NotificationsFragment extends SFragment implements
|
|||
bottomId = null;
|
||||
topId = null;
|
||||
|
||||
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||
setupNothingView();
|
||||
|
||||
return rootView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPostCreate() {
|
||||
super.onPostCreate();
|
||||
eventHub.getEvents()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe(event -> {
|
||||
if (event instanceof FavoriteEvent) {
|
||||
handleFavEvent((FavoriteEvent) event);
|
||||
} else if (event instanceof ReblogEvent) {
|
||||
handleReblogEvent((ReblogEvent) event);
|
||||
} else if (event instanceof BlockEvent) {
|
||||
removeAllByAccountId(((BlockEvent) event).getAccountId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setupNothingView() {
|
||||
Drawable top = AppCompatResources.getDrawable(Objects.requireNonNull(getContext()),
|
||||
R.drawable.elephant_friend);
|
||||
if (top != null) {
|
||||
top.setBounds(0, 0, top.getIntrinsicWidth() / 2, top.getIntrinsicHeight() / 2);
|
||||
}
|
||||
nothingMessageView.setCompoundDrawables(null, top, null, null);
|
||||
nothingMessageView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void handleFavEvent(FavoriteEvent event) {
|
||||
Pair<Integer, Notification> posAndNotification =
|
||||
findReplyPosition(event.getStatusId());
|
||||
if (posAndNotification == null) return;
|
||||
//noinspection ConstantConditions
|
||||
setFavovouriteForStatus(posAndNotification.first,
|
||||
posAndNotification.second.getStatus(),
|
||||
event.getFavourite());
|
||||
}
|
||||
|
||||
private void handleReblogEvent(ReblogEvent event) {
|
||||
Pair<Integer, Notification> posAndNotification = findReplyPosition(event.getStatusId());
|
||||
if (posAndNotification == null) return;
|
||||
//noinspection ConstantConditions
|
||||
setReblogForStatus(posAndNotification.first,
|
||||
posAndNotification.second.getStatus(),
|
||||
event.getReblog());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
|
@ -250,7 +314,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
|
||||
public void onLoadMore(int totalItemsCount, RecyclerView view) {
|
||||
NotificationsFragment.this.onLoadMore();
|
||||
}
|
||||
};
|
||||
|
@ -266,9 +330,6 @@ public class NotificationsFragment extends SFragment implements
|
|||
} else {
|
||||
TabLayout tabLayout = activity.findViewById(R.id.tab_layout);
|
||||
tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
|
||||
|
||||
LocalBroadcastManager.getInstance(activity)
|
||||
.unregisterReceiver(timelineReceiver);
|
||||
}
|
||||
|
||||
super.onDestroyView();
|
||||
|
@ -292,6 +353,18 @@ public class NotificationsFragment extends SFragment implements
|
|||
@Override
|
||||
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) {
|
||||
if (response.isSuccessful()) {
|
||||
setReblogForStatus(position, status, reblog);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setReblogForStatus(int position, Status status, boolean reblog) {
|
||||
status.setReblogged(reblog);
|
||||
|
||||
if (status.getReblog() != null) {
|
||||
|
@ -309,15 +382,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
|
||||
notifications.setPairedItem(position, newViewData);
|
||||
|
||||
adapter.updateItemWithNotify(position, newViewData, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
adapter.updateItemWithNotify(position, newViewData, true);
|
||||
}
|
||||
|
||||
|
||||
|
@ -329,6 +394,19 @@ public class NotificationsFragment extends SFragment implements
|
|||
@Override
|
||||
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) {
|
||||
if (response.isSuccessful()) {
|
||||
setFavovouriteForStatus(position, status, favourite);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setFavovouriteForStatus(int position, Status status, boolean favourite) {
|
||||
status.setFavourited(favourite);
|
||||
|
||||
if (status.getReblog() != null) {
|
||||
|
@ -346,16 +424,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
|
||||
notifications.setPairedItem(position, newViewData);
|
||||
|
||||
adapter.updateItemWithNotify(position, newViewData, false);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
adapter.updateItemWithNotify(position, newViewData, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -475,8 +544,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
adapter.update(notifications.getPairedCopy());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllByAccountId(String accountId) {
|
||||
private void removeAllByAccountId(String accountId) {
|
||||
// using iterator to safely remove items while iterating
|
||||
Iterator<Either<Placeholder, Notification>> iterator = notifications.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
|
@ -590,6 +658,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
adapter.setFooterState(FooterViewHolder.State.END);
|
||||
}
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) {
|
||||
|
@ -602,6 +671,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
}
|
||||
Log.e(TAG, "Fetch failure: " + exception.getMessage());
|
||||
fulfillAnyQueuedFetches(fetchEnd);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void saveNewestNotificationId(List<Notification> notifications) {
|
||||
|
@ -623,7 +693,6 @@ public class NotificationsFragment extends SFragment implements
|
|||
}
|
||||
|
||||
private boolean isBiggerThan(BigInteger newId, BigInteger lastShownNotificationId) {
|
||||
|
||||
return lastShownNotificationId.compareTo(newId) < 0;
|
||||
}
|
||||
|
||||
|
@ -736,4 +805,20 @@ public class NotificationsFragment extends SFragment implements
|
|||
notifications.clear();
|
||||
sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Pair<Integer, Notification> findReplyPosition(@NonNull String statusId) {
|
||||
for (int i = 0; i < notifications.size(); i++) {
|
||||
Notification notification = notifications.get(i).getAsRightOrNull();
|
||||
if (notification != null
|
||||
&& notification.getStatus() != null
|
||||
&& notification.getType() == Notification.Type.MENTION
|
||||
&& (statusId.equals(notification.getStatus().getId())
|
||||
|| (notification.getStatus().getReblog() != null
|
||||
&& statusId.equals(notification.getStatus().getReblog().getId())))) {
|
||||
return new Pair<>(i, notification);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import android.content.Context;
|
|||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.design.widget.BottomSheetBehavior;
|
||||
import android.support.v4.app.ActivityOptionsCompat;
|
||||
import android.support.v4.view.ViewCompat;
|
||||
import android.support.v7.widget.PopupMenu;
|
||||
|
@ -39,7 +40,6 @@ import com.keylesspalace.tusky.db.AccountEntity;
|
|||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.AdapterItemRemover;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.network.TimelineCases;
|
||||
import com.keylesspalace.tusky.util.HtmlUtils;
|
||||
|
@ -57,18 +57,19 @@ import javax.inject.Inject;
|
|||
* adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also
|
||||
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
|
||||
* up what needs to be where. */
|
||||
public abstract class SFragment extends BaseFragment implements AdapterItemRemover {
|
||||
public abstract class SFragment extends BaseFragment {
|
||||
protected static final int COMPOSE_RESULT = 1;
|
||||
|
||||
protected String loggedInAccountId;
|
||||
protected String loggedInUsername;
|
||||
|
||||
protected abstract TimelineCases timelineCases();
|
||||
protected abstract void removeItem(int position);
|
||||
|
||||
private BottomSheetActivity bottomSheetActivity;
|
||||
|
||||
@Inject
|
||||
protected MastodonApi mastodonApi;
|
||||
public MastodonApi mastodonApi;
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
|
|
|
@ -126,10 +126,6 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable {
|
|||
searchAdapter.removeStatusAtPosition(position)
|
||||
}
|
||||
|
||||
override fun removeAllByAccountId(accountId: String?) {
|
||||
// not supported
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
val status = searchAdapter.getStatusAtPosition(position)
|
||||
if(status != null) {
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
package com.keylesspalace.tusky.fragment;
|
||||
|
||||
import android.arch.core.util.Function;
|
||||
import android.arch.lifecycle.Lifecycle;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
@ -25,29 +26,40 @@ import android.support.annotation.NonNull;
|
|||
import android.support.annotation.Nullable;
|
||||
import android.support.design.widget.FloatingActionButton;
|
||||
import android.support.design.widget.TabLayout;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.support.v4.util.Pair;
|
||||
import android.support.v4.widget.SwipeRefreshLayout;
|
||||
import android.support.v7.content.res.AppCompatResources;
|
||||
import android.support.v7.recyclerview.extensions.AsyncDifferConfig;
|
||||
import android.support.v7.recyclerview.extensions.AsyncListDiffer;
|
||||
import android.support.v7.util.DiffUtil;
|
||||
import android.support.v7.util.ListUpdateCallback;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.support.v7.widget.SimpleItemAnimator;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.keylesspalace.tusky.BuildConfig;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.adapter.FooterViewHolder;
|
||||
import com.keylesspalace.tusky.adapter.TimelineAdapter;
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent;
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
|
||||
import com.keylesspalace.tusky.appstore.UnfollowEvent;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.network.TimelineCases;
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver;
|
||||
import com.keylesspalace.tusky.util.CollectionUtil;
|
||||
import com.keylesspalace.tusky.util.Either;
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink;
|
||||
|
@ -60,16 +72,20 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
import static com.uber.autodispose.AutoDispose.autoDisposable;
|
||||
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||
|
||||
public class TimelineFragment extends SFragment implements
|
||||
SwipeRefreshLayout.OnRefreshListener,
|
||||
StatusActionListener,
|
||||
|
@ -98,13 +114,18 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
|
||||
@Inject
|
||||
TimelineCases timelineCases;
|
||||
public TimelineCases timelineCases;
|
||||
@Inject
|
||||
public EventHub eventHub;
|
||||
|
||||
private SwipeRefreshLayout swipeRefreshLayout;
|
||||
private RecyclerView recyclerView;
|
||||
private ProgressBar progressBar;
|
||||
private TextView nothingMessageView;
|
||||
|
||||
private TimelineAdapter adapter;
|
||||
private Kind kind;
|
||||
private String hashtagOrId;
|
||||
private RecyclerView recyclerView;
|
||||
private LinearLayoutManager layoutManager;
|
||||
private EndlessOnScrollListener scrollListener;
|
||||
private TabLayout.OnTabSelectedListener onTabSelectedListener;
|
||||
|
@ -113,15 +134,16 @@ public class TimelineFragment extends SFragment implements
|
|||
private boolean filterRemoveRegex;
|
||||
private Matcher filterRemoveRegexMatcher;
|
||||
private boolean hideFab;
|
||||
private TimelineReceiver timelineReceiver;
|
||||
private boolean topLoading;
|
||||
private int topFetches;
|
||||
private boolean bottomLoading;
|
||||
private int bottomFetches;
|
||||
|
||||
@Nullable
|
||||
private String bottomId;
|
||||
@Nullable
|
||||
private String topId;
|
||||
private long maxPlaceholderId = -1;
|
||||
private boolean didLoadEverythingBottom;
|
||||
|
||||
private boolean alwaysShowSensitiveMedia;
|
||||
|
||||
|
@ -138,7 +160,8 @@ public class TimelineFragment extends SFragment implements
|
|||
if (status != null) {
|
||||
return ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia);
|
||||
} else {
|
||||
return new StatusViewData.Placeholder(false);
|
||||
Placeholder placeholder = input.getAsLeft();
|
||||
return new StatusViewData.Placeholder(placeholder.id, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -161,20 +184,21 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
|
||||
private static final class Placeholder {
|
||||
private final static Placeholder INSTANCE = new Placeholder();
|
||||
final long id;
|
||||
|
||||
public static Placeholder getInstance() {
|
||||
return INSTANCE;
|
||||
public static Placeholder getInstance(long id) {
|
||||
return new Placeholder(id);
|
||||
}
|
||||
|
||||
private Placeholder() {
|
||||
private Placeholder(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
|
||||
Bundle savedInstanceState) {
|
||||
Bundle arguments = getArguments();
|
||||
Bundle arguments = Objects.requireNonNull(getArguments());
|
||||
kind = Kind.valueOf(arguments.getString(KIND_ARG));
|
||||
if (kind == Kind.TAG || kind == Kind.USER || kind == Kind.LIST) {
|
||||
hashtagOrId = arguments.getString(HASHTAG_OR_ID_ARG);
|
||||
|
@ -182,14 +206,73 @@ public class TimelineFragment extends SFragment implements
|
|||
|
||||
final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
|
||||
|
||||
// Setup the SwipeRefreshLayout.
|
||||
Context context = getContext();
|
||||
recyclerView = rootView.findViewById(R.id.recycler_view);
|
||||
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
|
||||
progressBar = rootView.findViewById(R.id.progress_bar);
|
||||
nothingMessageView = rootView.findViewById(R.id.nothing_message);
|
||||
|
||||
adapter = new TimelineAdapter(dataSource, this);
|
||||
|
||||
|
||||
setupSwipeRefreshLayout();
|
||||
setupRecyclerView();
|
||||
updateAdapter();
|
||||
setupTimelinePreferences();
|
||||
setupNothingView();
|
||||
|
||||
topLoading = false;
|
||||
topFetches = 0;
|
||||
bottomId = null;
|
||||
topId = null;
|
||||
|
||||
|
||||
if (statuses.isEmpty()) {
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
bottomLoading = true;
|
||||
sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1);
|
||||
} else {
|
||||
progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
return rootView;
|
||||
}
|
||||
|
||||
private void setupTimelinePreferences() {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(
|
||||
getActivity());
|
||||
preferences.registerOnSharedPreferenceChangeListener(this);
|
||||
alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false);
|
||||
boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true);
|
||||
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
|
||||
|
||||
boolean filter = preferences.getBoolean("tabFilterHomeReplies", true);
|
||||
filterRemoveReplies = kind == Kind.HOME && !filter;
|
||||
|
||||
filter = preferences.getBoolean("tabFilterHomeBoosts", true);
|
||||
filterRemoveReblogs = kind == Kind.HOME && !filter;
|
||||
|
||||
String regexFilter = preferences.getString("tabFilterRegex", "");
|
||||
filterRemoveRegex = (kind == Kind.HOME
|
||||
|| kind == Kind.PUBLIC_LOCAL
|
||||
|| kind == Kind.PUBLIC_FEDERATED)
|
||||
&& !regexFilter.isEmpty();
|
||||
|
||||
if (filterRemoveRegex) {
|
||||
filterRemoveRegexMatcher = Pattern.compile(regexFilter, Pattern.CASE_INSENSITIVE)
|
||||
.matcher("");
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSwipeRefreshLayout() {
|
||||
Context context = Objects.requireNonNull(getContext());
|
||||
swipeRefreshLayout.setOnRefreshListener(this);
|
||||
swipeRefreshLayout.setColorSchemeResources(R.color.primary);
|
||||
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground));
|
||||
// Setup the RecyclerView.
|
||||
recyclerView = rootView.findViewById(R.id.recycler_view);
|
||||
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context,
|
||||
android.R.attr.colorBackground));
|
||||
}
|
||||
|
||||
private void setupRecyclerView() {
|
||||
Context context = Objects.requireNonNull(getContext());
|
||||
recyclerView.setHasFixedSize(true);
|
||||
layoutManager = new LinearLayoutManager(context);
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
|
@ -199,38 +282,58 @@ public class TimelineFragment extends SFragment implements
|
|||
R.drawable.status_divider_dark);
|
||||
divider.setDrawable(drawable);
|
||||
recyclerView.addItemDecoration(divider);
|
||||
adapter = new TimelineAdapter(this);
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(
|
||||
getActivity());
|
||||
preferences.registerOnSharedPreferenceChangeListener(this);
|
||||
alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false);
|
||||
boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true);
|
||||
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
|
||||
|
||||
// CWs are expanded without animation, buttons animate itself, we don't need it basically
|
||||
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||
|
||||
recyclerView.setAdapter(adapter);
|
||||
}
|
||||
|
||||
boolean filter = preferences.getBoolean("tabFilterHomeReplies", true);
|
||||
filterRemoveReplies = kind == Kind.HOME && !filter;
|
||||
@Override
|
||||
public void onPostCreate() {
|
||||
super.onPostCreate();
|
||||
|
||||
filter = preferences.getBoolean("tabFilterHomeBoosts", true);
|
||||
filterRemoveReblogs = kind == Kind.HOME && !filter;
|
||||
eventHub.getEvents()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe(event -> {
|
||||
if (event instanceof FavoriteEvent) {
|
||||
FavoriteEvent favEvent = ((FavoriteEvent) event);
|
||||
handleFavEvent(favEvent);
|
||||
} else if (event instanceof ReblogEvent) {
|
||||
ReblogEvent reblogEvent = (ReblogEvent) event;
|
||||
handleReblogEvent(reblogEvent);
|
||||
} else if (event instanceof UnfollowEvent) {
|
||||
if (kind == Kind.HOME) {
|
||||
String id = ((UnfollowEvent) event).getAccountId();
|
||||
removeAllByAccountId(id);
|
||||
}
|
||||
} else if (event instanceof BlockEvent) {
|
||||
String id = ((BlockEvent) event).getAccountId();
|
||||
removeAllByAccountId(id);
|
||||
} else if (event instanceof MuteEvent) {
|
||||
String id = ((MuteEvent) event).getAccountId();
|
||||
removeAllByAccountId(id);
|
||||
} else if (event instanceof StatusDeletedEvent) {
|
||||
String id = ((StatusDeletedEvent) event).getStatusId();
|
||||
deleteStatusById(id);
|
||||
} else if (event instanceof StatusComposedEvent) {
|
||||
Status status = ((StatusComposedEvent) event).getStatus();
|
||||
handleStatusComposeEvent(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String regexFilter = preferences.getString("tabFilterRegex", "");
|
||||
filterRemoveRegex = (kind == Kind.HOME || kind == Kind.PUBLIC_LOCAL || kind == Kind.PUBLIC_FEDERATED) && !regexFilter.isEmpty();
|
||||
if (filterRemoveRegex) filterRemoveRegexMatcher = Pattern.compile(regexFilter, Pattern.CASE_INSENSITIVE).matcher("");
|
||||
|
||||
timelineReceiver = new TimelineReceiver(this, this);
|
||||
LocalBroadcastManager.getInstance(context.getApplicationContext())
|
||||
.registerReceiver(timelineReceiver, TimelineReceiver.getFilter(kind));
|
||||
|
||||
statuses.clear();
|
||||
topLoading = false;
|
||||
topFetches = 0;
|
||||
bottomLoading = false;
|
||||
bottomFetches = 0;
|
||||
bottomId = null;
|
||||
topId = null;
|
||||
|
||||
return rootView;
|
||||
private void deleteStatusById(String id) {
|
||||
for (int i = 0; i < statuses.size(); i++) {
|
||||
Either<Placeholder, Status> either = statuses.get(i);
|
||||
if (either.isRight()
|
||||
&& id.equals(either.getAsRight().getId())) {
|
||||
statuses.remove(either);
|
||||
updateAdapter();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -238,7 +341,7 @@ public class TimelineFragment extends SFragment implements
|
|||
super.onActivityCreated(savedInstanceState);
|
||||
|
||||
if (jumpToTopAllowed()) {
|
||||
TabLayout layout = getActivity().findViewById(R.id.tab_layout);
|
||||
TabLayout layout = Objects.requireNonNull(getActivity()).findViewById(R.id.tab_layout);
|
||||
if (layout != null) {
|
||||
onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
|
||||
@Override
|
||||
|
@ -287,7 +390,7 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
|
||||
public void onLoadMore(int totalItemsCount, RecyclerView view) {
|
||||
TimelineFragment.this.onLoadMore();
|
||||
}
|
||||
};
|
||||
|
@ -295,7 +398,7 @@ public class TimelineFragment extends SFragment implements
|
|||
// Just use the basic scroll listener to load more statuses.
|
||||
scrollListener = new EndlessOnScrollListener(layoutManager) {
|
||||
@Override
|
||||
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
|
||||
public void onLoadMore(int totalItemsCount, RecyclerView view) {
|
||||
TimelineFragment.this.onLoadMore();
|
||||
}
|
||||
};
|
||||
|
@ -306,15 +409,25 @@ public class TimelineFragment extends SFragment implements
|
|||
@Override
|
||||
public void onDestroyView() {
|
||||
if (jumpToTopAllowed()) {
|
||||
TabLayout tabLayout = getActivity().findViewById(R.id.tab_layout);
|
||||
TabLayout tabLayout = Objects.requireNonNull(getActivity())
|
||||
.findViewById(R.id.tab_layout);
|
||||
if (tabLayout != null) {
|
||||
tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
|
||||
}
|
||||
}
|
||||
LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(timelineReceiver);
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
private void setupNothingView() {
|
||||
Drawable top = AppCompatResources.getDrawable(Objects.requireNonNull(getContext()),
|
||||
R.drawable.elephant_friend);
|
||||
if (top != null) {
|
||||
top.setBounds(0, 0, top.getIntrinsicWidth() / 2, top.getIntrinsicHeight() / 2);
|
||||
}
|
||||
nothingMessageView.setCompoundDrawables(null, top, null, null);
|
||||
nothingMessageView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefresh() {
|
||||
sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1);
|
||||
|
@ -333,6 +446,18 @@ public class TimelineFragment extends SFragment implements
|
|||
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
setRebloggedForStatus(position, status, reblog);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(TAG, "Failed to reblog status " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setRebloggedForStatus(int position, Status status, boolean reblog) {
|
||||
status.setReblogged(reblog);
|
||||
|
||||
if (status.getReblog() != null) {
|
||||
|
@ -348,15 +473,7 @@ public class TimelineFragment extends SFragment implements
|
|||
.setReblogged(reblog)
|
||||
.createStatusViewData();
|
||||
statuses.setPairedItem(actual.second, newViewData);
|
||||
adapter.changeItem(actual.second, newViewData, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(TAG, "Failed to reblog status " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -368,6 +485,18 @@ public class TimelineFragment extends SFragment implements
|
|||
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
setFavouriteForStatus(position, status, favourite);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setFavouriteForStatus(int position, Status status, boolean favourite) {
|
||||
status.setFavourited(favourite);
|
||||
|
||||
if (status.getReblog() != null) {
|
||||
|
@ -383,15 +512,7 @@ public class TimelineFragment extends SFragment implements
|
|||
.setFavourited(favourite)
|
||||
.createStatusViewData();
|
||||
statuses.setPairedItem(actual.second, newViewData);
|
||||
adapter.changeItem(actual.second, newViewData, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.getId(), t);
|
||||
}
|
||||
});
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -410,7 +531,7 @@ public class TimelineFragment extends SFragment implements
|
|||
((StatusViewData.Concrete) statuses.getPairedItem(position)))
|
||||
.setIsExpanded(expanded).createStatusViewData();
|
||||
statuses.setPairedItem(position, newViewData);
|
||||
adapter.changeItem(position, newViewData, false);
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -419,7 +540,7 @@ public class TimelineFragment extends SFragment implements
|
|||
((StatusViewData.Concrete) statuses.getPairedItem(position)))
|
||||
.setIsShowingSensitiveContent(isShowing).createStatusViewData();
|
||||
statuses.setPairedItem(position, newViewData);
|
||||
adapter.changeItem(position, newViewData, false);
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -434,9 +555,10 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), FetchEnd.MIDDLE, position);
|
||||
|
||||
StatusViewData newViewData = new StatusViewData.Placeholder(true);
|
||||
Placeholder placeholder = statuses.get(position).getAsLeft();
|
||||
StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.id, true);
|
||||
statuses.setPairedItem(position, newViewData);
|
||||
adapter.changeItem(position, newViewData, false);
|
||||
updateAdapter();
|
||||
} else {
|
||||
Log.e(TAG, "error loading more");
|
||||
}
|
||||
|
@ -530,10 +652,9 @@ public class TimelineFragment extends SFragment implements
|
|||
@Override
|
||||
public void removeItem(int position) {
|
||||
statuses.remove(position);
|
||||
adapter.update(statuses.getPairedCopy());
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllByAccountId(String accountId) {
|
||||
// using iterator to safely remove items while iterating
|
||||
Iterator<Either<Placeholder, Status>> iterator = statuses.iterator();
|
||||
|
@ -543,15 +664,34 @@ public class TimelineFragment extends SFragment implements
|
|||
iterator.remove();
|
||||
}
|
||||
}
|
||||
adapter.update(statuses.getPairedCopy());
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
private void onLoadMore() {
|
||||
if (didLoadEverythingBottom || bottomLoading) {
|
||||
return;
|
||||
}
|
||||
bottomLoading = true;
|
||||
|
||||
Either<Placeholder, Status> last = statuses.get(statuses.size() - 1);
|
||||
Placeholder placeholder;
|
||||
if (last.isRight()) {
|
||||
placeholder = newPlaceholder();
|
||||
statuses.add(Either.left(placeholder));
|
||||
} else {
|
||||
placeholder = last.getAsLeft();
|
||||
}
|
||||
statuses.setPairedItem(statuses.size() - 1,
|
||||
new StatusViewData.Placeholder(placeholder.id, true));
|
||||
|
||||
updateAdapter();
|
||||
|
||||
sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM, -1);
|
||||
}
|
||||
|
||||
private void fullyRefresh() {
|
||||
adapter.clear();
|
||||
statuses.clear();
|
||||
updateAdapter();
|
||||
sendFetchTimelineRequest(null, null, FetchEnd.TOP, -1);
|
||||
}
|
||||
|
||||
|
@ -560,7 +700,8 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
|
||||
private boolean actionButtonPresent() {
|
||||
return kind != Kind.TAG && kind != Kind.FAVOURITES;
|
||||
return kind != Kind.TAG && kind != Kind.FAVOURITES &&
|
||||
getActivity() instanceof ActionButtonActivity;
|
||||
}
|
||||
|
||||
private void jumpToTop() {
|
||||
|
@ -599,17 +740,6 @@ public class TimelineFragment extends SFragment implements
|
|||
topFetches++;
|
||||
return;
|
||||
}
|
||||
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
|
||||
bottomFetches++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromId != null || adapter.getItemCount() <= 1) {
|
||||
/* When this is called by the EndlessScrollListener it cannot refresh the footer state
|
||||
* using adapter.notifyItemChanged. So its necessary to postpone doing so until a
|
||||
* convenient time for the UI thread using a Runnable. */
|
||||
recyclerView.post(() -> adapter.setFooterState(FooterViewHolder.State.LOADING));
|
||||
}
|
||||
|
||||
Callback<List<Status>> callback = new Callback<List<Status>>() {
|
||||
@Override
|
||||
|
@ -635,6 +765,7 @@ public class TimelineFragment extends SFragment implements
|
|||
|
||||
private void onFetchTimelineSuccess(List<Status> statuses, String linkHeader,
|
||||
FetchEnd fetchEnd, int pos) {
|
||||
|
||||
// We filled the hole (or reached the end) if the server returned less statuses than we
|
||||
// we asked for.
|
||||
boolean fullFetch = statuses.size() >= LOAD_AT_ONCE;
|
||||
|
@ -660,7 +791,13 @@ public class TimelineFragment extends SFragment implements
|
|||
if (next != null) {
|
||||
fromId = next.uri.getQueryParameter("max_id");
|
||||
}
|
||||
if (adapter.getItemCount() > 1) {
|
||||
if (!this.statuses.isEmpty()
|
||||
&& !this.statuses.get(this.statuses.size() - 1).isRight()) {
|
||||
this.statuses.remove(this.statuses.size() - 1);
|
||||
updateAdapter();
|
||||
}
|
||||
int oldSize = this.statuses.size();
|
||||
if (this.statuses.size() > 1) {
|
||||
addItems(statuses, fromId);
|
||||
} else {
|
||||
/* If this is the first fetch, also save the id from the "previous" link and
|
||||
|
@ -673,39 +810,45 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
updateStatuses(statuses, fromId, uptoId, fullFetch);
|
||||
}
|
||||
if (this.statuses.size() == oldSize) {
|
||||
// This may be a brittle check but seems like it works
|
||||
// Can we check it using headers somehow? Do all server support them?
|
||||
didLoadEverythingBottom = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
fulfillAnyQueuedFetches(fetchEnd);
|
||||
if (statuses.size() == 0 && adapter.getItemCount() == 1) {
|
||||
adapter.setFooterState(FooterViewHolder.State.EMPTY);
|
||||
} else {
|
||||
adapter.setFooterState(FooterViewHolder.State.END);
|
||||
}
|
||||
progressBar.setVisibility(View.GONE);
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
if (this.statuses.size() == 0) {
|
||||
nothingMessageView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) {
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
|
||||
if (fetchEnd == FetchEnd.MIDDLE && !statuses.get(position).isRight()) {
|
||||
StatusViewData newViewData = new StatusViewData.Placeholder(false);
|
||||
Placeholder placeholder = statuses.get(position).getAsLeftOrNull();
|
||||
StatusViewData newViewData;
|
||||
if (placeholder == null) {
|
||||
placeholder = newPlaceholder();
|
||||
}
|
||||
newViewData = new StatusViewData.Placeholder(placeholder.id, false);
|
||||
statuses.setPairedItem(position, newViewData);
|
||||
adapter.changeItem(position, newViewData, true);
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
Log.e(TAG, "Fetch Failure: " + exception.getMessage());
|
||||
fulfillAnyQueuedFetches(fetchEnd);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
|
||||
switch (fetchEnd) {
|
||||
case BOTTOM: {
|
||||
bottomLoading = false;
|
||||
if (bottomFetches > 0) {
|
||||
bottomFetches--;
|
||||
onLoadMore();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TOP: {
|
||||
|
@ -744,7 +887,7 @@ public class TimelineFragment extends SFragment implements
|
|||
topId = toId;
|
||||
}
|
||||
|
||||
List<Either<Placeholder, Status>> liftedNew = listStatusList(newStatuses);
|
||||
List<Either<Placeholder, Status>> liftedNew = liftStatusList(newStatuses);
|
||||
|
||||
if (statuses.isEmpty()) {
|
||||
statuses.addAll(liftedNew);
|
||||
|
@ -758,39 +901,35 @@ public class TimelineFragment extends SFragment implements
|
|||
int newIndex = liftedNew.indexOf(statuses.get(0));
|
||||
if (newIndex == -1) {
|
||||
if (index == -1 && fullFetch) {
|
||||
liftedNew.add(Either.left(Placeholder.getInstance()));
|
||||
liftedNew.add(Either.left(newPlaceholder()));
|
||||
}
|
||||
statuses.addAll(0, liftedNew);
|
||||
} else {
|
||||
statuses.addAll(0, liftedNew.subList(0, newIndex));
|
||||
}
|
||||
}
|
||||
adapter.update(statuses.getPairedCopy());
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
private void addItems(List<Status> newStatuses, @Nullable String fromId) {
|
||||
if (ListUtils.isEmpty(newStatuses)) {
|
||||
return;
|
||||
}
|
||||
int end = statuses.size();
|
||||
Status last = statuses.get(end - 1).getAsRightOrNull();
|
||||
Status last = null;
|
||||
for (int i = statuses.size() - 1; i >= 0; i--) {
|
||||
if (statuses.get(i).isRight()) {
|
||||
last = statuses.get(i).getAsRight();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// I was about to replace findStatus with indexOf but it is incorrect to compare value
|
||||
// types by ID anyway and we should change equals() for Status, I think, so this makes sense
|
||||
if (last != null && !findStatus(newStatuses, last.getId())) {
|
||||
statuses.addAll(listStatusList(newStatuses));
|
||||
List<StatusViewData> newViewDatas = statuses.getPairedCopy()
|
||||
.subList(statuses.size() - newStatuses.size(), statuses.size());
|
||||
if (BuildConfig.DEBUG && newStatuses.size() != newViewDatas.size()) {
|
||||
String error = String.format(Locale.getDefault(),
|
||||
"Incorrectly got statusViewData sublist." +
|
||||
" newStatuses.size == %d newViewDatas.size == %d, statuses.size == %d",
|
||||
newStatuses.size(), newViewDatas.size(), statuses.size());
|
||||
throw new AssertionError(error);
|
||||
}
|
||||
statuses.addAll(liftStatusList(newStatuses));
|
||||
if (fromId != null) {
|
||||
bottomId = fromId;
|
||||
}
|
||||
adapter.addItems(newViewDatas);
|
||||
updateAdapter();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -801,18 +940,18 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
|
||||
if (ListUtils.isEmpty(newStatuses)) {
|
||||
adapter.update(statuses.getPairedCopy());
|
||||
updateAdapter();
|
||||
return;
|
||||
}
|
||||
|
||||
List<Either<Placeholder, Status>> liftedNew = listStatusList(newStatuses);
|
||||
List<Either<Placeholder, Status>> liftedNew = liftStatusList(newStatuses);
|
||||
|
||||
if (fullFetch) {
|
||||
liftedNew.add(Either.left(Placeholder.getInstance()));
|
||||
liftedNew.add(Either.left(newPlaceholder()));
|
||||
}
|
||||
|
||||
statuses.addAll(pos, liftedNew);
|
||||
adapter.update(statuses.getPairedCopy());
|
||||
updateAdapter();
|
||||
|
||||
}
|
||||
|
||||
|
@ -825,6 +964,19 @@ public class TimelineFragment extends SFragment implements
|
|||
return false;
|
||||
}
|
||||
|
||||
private int findStatusOrReblogPositionById(@NonNull String statusId) {
|
||||
for (int i = 0; i < statuses.size(); i++) {
|
||||
Status status = statuses.get(i).getAsRightOrNull();
|
||||
if (status != null
|
||||
&& (statusId.equals(status.getId())
|
||||
|| (status.getReblog() != null
|
||||
&& statusId.equals(status.getReblog().getId())))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private final Function<Status, Either<Placeholder, Status>> statusLifter =
|
||||
Either::right;
|
||||
|
||||
|
@ -851,7 +1003,111 @@ public class TimelineFragment extends SFragment implements
|
|||
return new Pair<>(statusToUpdate, positionToUpdate);
|
||||
}
|
||||
|
||||
private List<Either<Placeholder, Status>> listStatusList(List<Status> list) {
|
||||
private void handleReblogEvent(@NonNull ReblogEvent reblogEvent) {
|
||||
int pos = findStatusOrReblogPositionById(reblogEvent.getStatusId());
|
||||
if (pos < 0) return;
|
||||
Status status = statuses.get(pos).getAsRight();
|
||||
setRebloggedForStatus(pos, status, reblogEvent.getReblog());
|
||||
}
|
||||
|
||||
private void handleFavEvent(@NonNull FavoriteEvent favEvent) {
|
||||
int pos = findStatusOrReblogPositionById(favEvent.getStatusId());
|
||||
if (pos < 0) return;
|
||||
Status status = statuses.get(pos).getAsRight();
|
||||
setFavouriteForStatus(pos, status, favEvent.getFavourite());
|
||||
}
|
||||
|
||||
private void handleStatusComposeEvent(@NonNull Status status) {
|
||||
switch (kind) {
|
||||
case HOME:
|
||||
case PUBLIC_FEDERATED:
|
||||
case PUBLIC_LOCAL:
|
||||
break;
|
||||
case USER:
|
||||
if (status.getAccount().getId().equals(hashtagOrId)) {
|
||||
break;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
case TAG:
|
||||
case FAVOURITES:
|
||||
case LIST:
|
||||
return;
|
||||
}
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
private List<Either<Placeholder, Status>> liftStatusList(List<Status> list) {
|
||||
return CollectionUtil.map(list, statusLifter);
|
||||
}
|
||||
|
||||
private Placeholder newPlaceholder() {
|
||||
Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId);
|
||||
maxPlaceholderId--;
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
private void updateAdapter() {
|
||||
differ.submitList(statuses.getPairedCopy());
|
||||
}
|
||||
|
||||
private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() {
|
||||
@Override
|
||||
public void onInserted(int position, int count) {
|
||||
adapter.notifyItemRangeInserted(position, count);
|
||||
if (position == 0
|
||||
&& layoutManager.findFirstVisibleItemPosition() == 0
|
||||
&& (swipeRefreshLayout.getVisibility() == View.VISIBLE
|
||||
|| progressBar.getVisibility() == View.VISIBLE)) {
|
||||
recyclerView.post(() -> layoutManager.scrollToPosition(0));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoved(int position, int count) {
|
||||
adapter.notifyItemRangeRemoved(position, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMoved(int fromPosition, int toPosition) {
|
||||
adapter.notifyItemMoved(fromPosition, toPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChanged(int position, int count, Object payload) {
|
||||
adapter.notifyItemRangeChanged(position, count, payload);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
private final AsyncListDiffer<StatusViewData>
|
||||
differ = new AsyncListDiffer<>(listUpdateCallback,
|
||||
new AsyncDifferConfig.Builder<>(diffCallback).build());
|
||||
|
||||
private final TimelineAdapter.AdapterDataSource<StatusViewData> dataSource =
|
||||
new TimelineAdapter.AdapterDataSource<StatusViewData>() {
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return differ.getCurrentList().size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatusViewData getItemAt(int pos) {
|
||||
return differ.getCurrentList().get(pos);
|
||||
}
|
||||
};
|
||||
|
||||
private static final DiffUtil.ItemCallback<StatusViewData> diffCallback
|
||||
= new DiffUtil.ItemCallback<StatusViewData>() {
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(StatusViewData oldItem, StatusViewData newItem) {
|
||||
return oldItem.getViewDataId() == newItem.getViewDataId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(StatusViewData oldItem, StatusViewData newItem) {
|
||||
return oldItem.deepEquals(newItem);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
package com.keylesspalace.tusky.fragment;
|
||||
|
||||
import android.arch.core.util.Function;
|
||||
import android.arch.lifecycle.Lifecycle;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
@ -24,11 +25,12 @@ import android.preference.PreferenceManager;
|
|||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.design.widget.Snackbar;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.support.v4.util.Pair;
|
||||
import android.support.v4.widget.SwipeRefreshLayout;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.support.v7.widget.SimpleItemAnimator;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
|
@ -39,14 +41,19 @@ import com.keylesspalace.tusky.BuildConfig;
|
|||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.ViewThreadActivity;
|
||||
import com.keylesspalace.tusky.adapter.ThreadAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent;
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent;
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Card;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.entity.StatusContext;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.network.TimelineCases;
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver;
|
||||
import com.keylesspalace.tusky.util.PairedList;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.util.ViewDataUtils;
|
||||
|
@ -59,22 +66,29 @@ import java.util.Locale;
|
|||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
import static com.uber.autodispose.AutoDispose.*;
|
||||
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.*;
|
||||
|
||||
public final class ViewThreadFragment extends SFragment implements
|
||||
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable {
|
||||
private static final String TAG = "ViewThreadFragment";
|
||||
|
||||
@Inject
|
||||
public TimelineCases timelineCases;
|
||||
@Inject
|
||||
public MastodonApi mastodonApi;
|
||||
@Inject
|
||||
public EventHub eventHub;
|
||||
|
||||
private SwipeRefreshLayout swipeRefreshLayout;
|
||||
private RecyclerView recyclerView;
|
||||
private ThreadAdapter adapter;
|
||||
private String thisThreadsStatusId;
|
||||
private TimelineReceiver timelineReceiver;
|
||||
private Card card;
|
||||
private boolean alwaysShowSensitiveMedia;
|
||||
|
||||
|
@ -101,6 +115,36 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
return timelineCases;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
adapter = new ThreadAdapter(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPostCreate() {
|
||||
super.onPostCreate();
|
||||
|
||||
eventHub.getEvents()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe(event -> {
|
||||
if (event instanceof FavoriteEvent) {
|
||||
handleFavEvent((FavoriteEvent) event);
|
||||
} else if (event instanceof ReblogEvent) {
|
||||
handleReblogEvent((ReblogEvent) event);
|
||||
} else if (event instanceof BlockEvent) {
|
||||
removeAllByAccountId(((BlockEvent) event).getAccountId());
|
||||
} else if (event instanceof StatusComposedEvent) {
|
||||
handleStatusComposedEvent((StatusComposedEvent) event);
|
||||
} else if (event instanceof StatusDeletedEvent) {
|
||||
handleStatusDeletedEvent((StatusDeletedEvent) event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState) {
|
||||
|
@ -128,7 +172,6 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
R.drawable.conversation_thread_line_dark);
|
||||
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context,
|
||||
threadLineDrawable));
|
||||
adapter = new ThreadAdapter(this);
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(
|
||||
getActivity());
|
||||
alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false);
|
||||
|
@ -139,19 +182,11 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
statuses.clear();
|
||||
thisThreadsStatusId = null;
|
||||
|
||||
timelineReceiver = new TimelineReceiver(this, this);
|
||||
LocalBroadcastManager.getInstance(context.getApplicationContext())
|
||||
.registerReceiver(timelineReceiver, TimelineReceiver.getFilter(null));
|
||||
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||
|
||||
return rootView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
LocalBroadcastManager.getInstance(getContext())
|
||||
.unregisterReceiver(timelineReceiver);
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
|
@ -202,6 +237,20 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
@Override
|
||||
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
|
||||
if (response.isSuccessful()) {
|
||||
setReblogForStatus(position, status, reblog);
|
||||
eventHub.dispatch(new ReblogEvent(status.getId(), reblog));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId());
|
||||
t.printStackTrace();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setReblogForStatus(int position, Status status, boolean reblog) {
|
||||
status.setReblogged(reblog);
|
||||
|
||||
if (status.getReblog() != null) {
|
||||
|
@ -216,16 +265,7 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
|
||||
|
||||
statuses.setPairedItem(position, newViewData);
|
||||
adapter.setItem(position, newViewData, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId());
|
||||
t.printStackTrace();
|
||||
}
|
||||
});
|
||||
adapter.setItem(position, newViewData, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -235,6 +275,20 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
@Override
|
||||
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
|
||||
if (response.isSuccessful()) {
|
||||
setFavForStatus(position, status, favourite);
|
||||
eventHub.dispatch(new FavoriteEvent(status.getId(), favourite));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId());
|
||||
t.printStackTrace();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setFavForStatus(int position, Status status, boolean favourite) {
|
||||
status.setFavourited(favourite);
|
||||
|
||||
if (status.getReblog() != null) {
|
||||
|
@ -249,16 +303,7 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
|
||||
|
||||
statuses.setPairedItem(position, newViewData);
|
||||
adapter.setItem(position, newViewData, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
|
||||
Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId());
|
||||
t.printStackTrace();
|
||||
}
|
||||
});
|
||||
adapter.setItem(position, newViewData, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -334,8 +379,7 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
adapter.setStatuses(statuses.getPairedCopy());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllByAccountId(String accountId) {
|
||||
private void removeAllByAccountId(String accountId) {
|
||||
Status status = null;
|
||||
if (!statuses.isEmpty()) {
|
||||
status = statuses.get(statusIndex);
|
||||
|
@ -532,6 +576,69 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
statuses.clear();
|
||||
adapter.clear();
|
||||
}
|
||||
|
||||
private void handleFavEvent(FavoriteEvent event) {
|
||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
||||
if (posAndStatus == null) return;
|
||||
//noinspection ConstantConditions
|
||||
setFavForStatus(posAndStatus.first, posAndStatus.second, event.getFavourite());
|
||||
}
|
||||
|
||||
private void handleReblogEvent(ReblogEvent event) {
|
||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
||||
if (posAndStatus == null) return;
|
||||
//noinspection ConstantConditions
|
||||
setReblogForStatus(posAndStatus.first, posAndStatus.second, event.getReblog());
|
||||
}
|
||||
|
||||
private void handleStatusComposedEvent(StatusComposedEvent event) {
|
||||
Status eventStatus = event.getStatus();
|
||||
if (eventStatus.getInReplyToId() == null) return;
|
||||
|
||||
if (eventStatus.getInReplyToId().equals(statuses.get(statusIndex).getId())) {
|
||||
insertStatus(eventStatus, statuses.size());
|
||||
} else {
|
||||
// If new status is a reply to some status in the thread, insert new status after it
|
||||
// We only check statuses below main status, ones on top don't belong to this thread
|
||||
for (int i = statusIndex; i < statuses.size(); i++) {
|
||||
Status status = statuses.get(i);
|
||||
if (eventStatus.getInReplyToId().equals(status.getId())) {
|
||||
insertStatus(eventStatus, i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void insertStatus(Status status, int at) {
|
||||
statuses.add(at, status);
|
||||
adapter.addItem(at, statuses.getPairedItem(at));
|
||||
}
|
||||
|
||||
private void handleStatusDeletedEvent(StatusDeletedEvent event) {
|
||||
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
|
||||
if (posAndStatus == null) return;
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
int pos = posAndStatus.first;
|
||||
statuses.remove(pos);
|
||||
adapter.removeItem(pos);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Pair<Integer, Status> findStatusAndPos(@NonNull String statusId) {
|
||||
for (int i = 0; i < statuses.size(); i++) {
|
||||
if (statusId.equals(statuses.get(i).getId())) {
|
||||
return new Pair<>(i, statuses.get(i));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void updateRevealIcon() {
|
||||
ViewThreadActivity activity = ((ViewThreadActivity) getActivity());
|
||||
if (activity == null) return;
|
||||
|
|
|
@ -1,21 +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.interfaces;
|
||||
|
||||
public interface AdapterItemRemover {
|
||||
void removeItem(int position);
|
||||
void removeAllByAccountId(String accountId);
|
||||
}
|
|
@ -15,11 +15,12 @@
|
|||
|
||||
package com.keylesspalace.tusky.network
|
||||
|
||||
import android.content.Intent
|
||||
import android.support.v4.content.LocalBroadcastManager
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
|
@ -39,7 +40,7 @@ interface TimelineCases {
|
|||
|
||||
class TimelineCasesImpl(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val broadcastManager: LocalBroadcastManager
|
||||
private val eventHub: EventHub
|
||||
) : TimelineCases {
|
||||
override fun reblogWithCallback(status: Status, reblog: Boolean, callback: Callback<Status>) {
|
||||
val id = status.actionableId
|
||||
|
@ -70,9 +71,7 @@ class TimelineCasesImpl(
|
|||
|
||||
override fun onFailure(call: Call<Relationship>, t: Throwable) {}
|
||||
})
|
||||
val intent = Intent(TimelineReceiver.Types.MUTE_ACCOUNT)
|
||||
intent.putExtra("id", id)
|
||||
broadcastManager.sendBroadcast(intent)
|
||||
eventHub.dispatch(MuteEvent(id))
|
||||
}
|
||||
|
||||
override fun block(id: String) {
|
||||
|
@ -82,9 +81,8 @@ class TimelineCasesImpl(
|
|||
|
||||
override fun onFailure(call: Call<Relationship>, t: Throwable) {}
|
||||
})
|
||||
val intent = Intent(TimelineReceiver.Types.BLOCK_ACCOUNT)
|
||||
intent.putExtra("id", id)
|
||||
broadcastManager.sendBroadcast(intent)
|
||||
eventHub.dispatch(BlockEvent(id))
|
||||
|
||||
}
|
||||
|
||||
override fun delete(id: String) {
|
||||
|
@ -94,6 +92,7 @@ class TimelineCasesImpl(
|
|||
|
||||
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {}
|
||||
})
|
||||
eventHub.dispatch(StatusDeletedEvent(id))
|
||||
}
|
||||
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
package com.keylesspalace.tusky.receiver;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.widget.SwipeRefreshLayout;
|
||||
|
||||
import com.keylesspalace.tusky.fragment.TimelineFragment;
|
||||
import com.keylesspalace.tusky.interfaces.AdapterItemRemover;
|
||||
|
||||
public class TimelineReceiver extends BroadcastReceiver {
|
||||
public static final class Types {
|
||||
public static final String UNFOLLOW_ACCOUNT = "UNFOLLOW_ACCOUNT";
|
||||
public static final String BLOCK_ACCOUNT = "BLOCK_ACCOUNT";
|
||||
public static final String MUTE_ACCOUNT = "MUTE_ACCOUNT";
|
||||
public static final String STATUS_COMPOSED = "STATUS_COMPOSED";
|
||||
}
|
||||
|
||||
private AdapterItemRemover adapter;
|
||||
private SwipeRefreshLayout.OnRefreshListener refreshListener;
|
||||
|
||||
public TimelineReceiver(AdapterItemRemover adapter) {
|
||||
super();
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
public TimelineReceiver(AdapterItemRemover adapter,
|
||||
SwipeRefreshLayout.OnRefreshListener refreshListener) {
|
||||
super();
|
||||
this.adapter = adapter;
|
||||
this.refreshListener = refreshListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, final Intent intent) {
|
||||
switch (intent.getAction()) {
|
||||
case Types.STATUS_COMPOSED: {
|
||||
if (refreshListener != null) {
|
||||
refreshListener.onRefresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
String id = intent.getStringExtra("id");
|
||||
adapter.removeAllByAccountId(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static IntentFilter getFilter(@Nullable TimelineFragment.Kind kind) {
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
if (kind == TimelineFragment.Kind.HOME) {
|
||||
intentFilter.addAction(Types.UNFOLLOW_ACCOUNT);
|
||||
}
|
||||
intentFilter.addAction(Types.BLOCK_ACCOUNT);
|
||||
intentFilter.addAction(Types.MUTE_ACCOUNT);
|
||||
if (kind == null
|
||||
|| kind == TimelineFragment.Kind.HOME
|
||||
|| kind == TimelineFragment.Kind.PUBLIC_FEDERATED
|
||||
|| kind == TimelineFragment.Kind.PUBLIC_LOCAL) {
|
||||
intentFilter.addAction(Types.STATUS_COMPOSED);
|
||||
}
|
||||
|
||||
return intentFilter;
|
||||
}
|
||||
}
|
|
@ -12,15 +12,15 @@ import android.os.Parcelable
|
|||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.ServiceCompat
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.LocalBroadcastManager
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.TuskyApplication
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.receiver.TimelineReceiver
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper
|
||||
import com.keylesspalace.tusky.util.StringUtils
|
||||
import dagger.android.AndroidInjection
|
||||
|
@ -30,14 +30,19 @@ import retrofit2.Callback
|
|||
import retrofit2.Response
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendTootService: Service(), Injectable {
|
||||
class SendTootService : Service(), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
@Inject
|
||||
lateinit var database: AppDatabase
|
||||
|
||||
private lateinit var saveTootHelper: SaveTootHelper
|
||||
|
||||
|
@ -50,7 +55,7 @@ class SendTootService: Service(), Injectable {
|
|||
|
||||
override fun onCreate() {
|
||||
AndroidInjection.inject(this)
|
||||
saveTootHelper = SaveTootHelper(TuskyApplication.getDB().tootDao(), this)
|
||||
saveTootHelper = SaveTootHelper(database.tootDao(), this)
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
|
@ -60,13 +65,9 @@ class SendTootService: Service(), Injectable {
|
|||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
|
||||
if(intent.hasExtra(KEY_TOOT)) {
|
||||
|
||||
if (intent.hasExtra(KEY_TOOT)) {
|
||||
val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT)
|
||||
|
||||
if (tootToSend == null) {
|
||||
throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
|
||||
}
|
||||
?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
|
||||
|
@ -88,7 +89,7 @@ class SendTootService: Service(), Injectable {
|
|||
.setColor(ContextCompat.getColor(this, R.color.primary))
|
||||
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
|
||||
|
||||
if(tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
|
||||
startForeground(sendingNotificationId, builder.build())
|
||||
} else {
|
||||
|
@ -100,7 +101,7 @@ class SendTootService: Service(), Injectable {
|
|||
|
||||
} else {
|
||||
|
||||
if(intent.hasExtra(KEY_CANCEL)) {
|
||||
if (intent.hasExtra(KEY_CANCEL)) {
|
||||
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
|
||||
}
|
||||
|
||||
|
@ -118,7 +119,7 @@ class SendTootService: Service(), Injectable {
|
|||
// when account == null, user has logged out, cancel sending
|
||||
val account = accountManager.getAccountById(tootToSend.accountId)
|
||||
|
||||
if(account == null) {
|
||||
if (account == null) {
|
||||
tootsToSend.remove(tootId)
|
||||
notificationManager.cancel(tootId)
|
||||
stopSelfWhenDone()
|
||||
|
@ -142,21 +143,19 @@ class SendTootService: Service(), Injectable {
|
|||
|
||||
sendCalls[tootId] = sendCall
|
||||
|
||||
val callback = object: Callback<Status> {
|
||||
val callback = object : Callback<Status> {
|
||||
override fun onResponse(call: Call<Status>, response: Response<Status>) {
|
||||
|
||||
tootsToSend.remove(tootId)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
|
||||
val intent = Intent(TimelineReceiver.Types.STATUS_COMPOSED)
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||
|
||||
// If the status was loaded from a draft, delete the draft and associated media files.
|
||||
if(tootToSend.savedTootUid != 0) {
|
||||
if (tootToSend.savedTootUid != 0) {
|
||||
saveTootHelper.deleteDraft(tootToSend.savedTootUid)
|
||||
}
|
||||
|
||||
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
|
||||
|
||||
notificationManager.cancel(tootId)
|
||||
|
||||
} else {
|
||||
|
@ -179,7 +178,7 @@ class SendTootService: Service(), Injectable {
|
|||
}
|
||||
|
||||
override fun onFailure(call: Call<Status>, t: Throwable) {
|
||||
var backoff = 1000L*tootToSend.retries
|
||||
var backoff = TimeUnit.SECONDS.toMillis(tootToSend.retries.toLong())
|
||||
if (backoff > MAX_RETRY_INTERVAL) {
|
||||
backoff = MAX_RETRY_INTERVAL
|
||||
}
|
||||
|
@ -206,7 +205,7 @@ class SendTootService: Service(), Injectable {
|
|||
|
||||
private fun cancelSending(tootId: Int) {
|
||||
val tootToCancel = tootsToSend.remove(tootId)
|
||||
if(tootToCancel != null) {
|
||||
if (tootToCancel != null) {
|
||||
val sendCall = sendCalls.remove(tootId)
|
||||
sendCall?.cancel()
|
||||
|
||||
|
@ -259,7 +258,7 @@ class SendTootService: Service(), Injectable {
|
|||
private const val KEY_CANCEL = "cancel_id"
|
||||
private const val CHANNEL_ID = "send_toots"
|
||||
|
||||
private const val MAX_RETRY_INTERVAL = 60*1000L // 1 minute
|
||||
private val MAX_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(1)
|
||||
|
||||
private var sendingNotificationId = -1 // use negative ids to not clash with other notis
|
||||
private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis
|
||||
|
@ -320,4 +319,4 @@ data class TootToSend(val text: String,
|
|||
val accountId: Long,
|
||||
val savedTootUid: Int,
|
||||
val idempotencyKey: String,
|
||||
var retries: Int): Parcelable
|
||||
var retries: Int) : Parcelable
|
||||
|
|
|
@ -20,18 +20,12 @@ import android.support.v7.widget.RecyclerView;
|
|||
|
||||
public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListener {
|
||||
private static final int VISIBLE_THRESHOLD = 15;
|
||||
private int currentPage;
|
||||
private int previousTotalItemCount;
|
||||
private boolean loading;
|
||||
private int startingPageIndex;
|
||||
private LinearLayoutManager layoutManager;
|
||||
|
||||
public EndlessOnScrollListener(LinearLayoutManager layoutManager) {
|
||||
this.layoutManager = layoutManager;
|
||||
currentPage = 0;
|
||||
previousTotalItemCount = 0;
|
||||
loading = true;
|
||||
startingPageIndex = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -39,28 +33,21 @@ public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListe
|
|||
int totalItemCount = layoutManager.getItemCount();
|
||||
int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();
|
||||
if (totalItemCount < previousTotalItemCount) {
|
||||
currentPage = startingPageIndex;
|
||||
previousTotalItemCount = totalItemCount;
|
||||
if (totalItemCount == 0) {
|
||||
loading = true;
|
||||
|
||||
}
|
||||
}
|
||||
if (loading && totalItemCount > previousTotalItemCount) {
|
||||
loading = false;
|
||||
if (totalItemCount != previousTotalItemCount) {
|
||||
previousTotalItemCount = totalItemCount;
|
||||
}
|
||||
if (!loading && lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) {
|
||||
currentPage++;
|
||||
onLoadMore(currentPage, totalItemCount, view);
|
||||
loading = true;
|
||||
|
||||
if (lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) {
|
||||
onLoadMore(totalItemCount, view);
|
||||
}
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
currentPage = startingPageIndex;
|
||||
previousTotalItemCount = 0;
|
||||
loading = true;
|
||||
}
|
||||
|
||||
public abstract void onLoadMore(int page, int totalItemsCount, RecyclerView view);
|
||||
public abstract void onLoadMore(int totalItemsCount, RecyclerView view);
|
||||
}
|
||||
|
|
|
@ -24,9 +24,11 @@ import com.keylesspalace.tusky.entity.Emoji;
|
|||
import com.keylesspalace.tusky.entity.Status;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Created by charlag on 11/07/2017.
|
||||
|
@ -40,6 +42,10 @@ public abstract class StatusViewData {
|
|||
private StatusViewData() {
|
||||
}
|
||||
|
||||
public abstract long getViewDataId();
|
||||
|
||||
public abstract boolean deepEquals(StatusViewData other);
|
||||
|
||||
public static final class Concrete extends StatusViewData {
|
||||
private final String id;
|
||||
private final Spanned content;
|
||||
|
@ -214,18 +220,84 @@ public abstract class StatusViewData {
|
|||
return card;
|
||||
}
|
||||
|
||||
@Override public long getViewDataId() {
|
||||
// Chance of collision is super low and impact of mistake is low as well
|
||||
return getId().hashCode();
|
||||
}
|
||||
|
||||
public boolean deepEquals(StatusViewData o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
Concrete concrete = (Concrete) o;
|
||||
return reblogged == concrete.reblogged &&
|
||||
favourited == concrete.favourited &&
|
||||
isSensitive == concrete.isSensitive &&
|
||||
isExpanded == concrete.isExpanded &&
|
||||
isShowingContent == concrete.isShowingContent &&
|
||||
reblogsCount == concrete.reblogsCount &&
|
||||
favouritesCount == concrete.favouritesCount &&
|
||||
rebloggingEnabled == concrete.rebloggingEnabled &&
|
||||
Objects.equals(id, concrete.id) &&
|
||||
Objects.equals(content, concrete.content) &&
|
||||
Objects.equals(spoilerText, concrete.spoilerText) &&
|
||||
visibility == concrete.visibility &&
|
||||
Objects.equals(attachments, concrete.attachments) &&
|
||||
Objects.equals(rebloggedByUsername, concrete.rebloggedByUsername) &&
|
||||
Objects.equals(rebloggedAvatar, concrete.rebloggedAvatar) &&
|
||||
Objects.equals(userFullName, concrete.userFullName) &&
|
||||
Objects.equals(nickname, concrete.nickname) &&
|
||||
Objects.equals(avatar, concrete.avatar) &&
|
||||
Objects.equals(createdAt, concrete.createdAt) &&
|
||||
Objects.equals(inReplyToId, concrete.inReplyToId) &&
|
||||
Arrays.equals(mentions, concrete.mentions) &&
|
||||
Objects.equals(senderId, concrete.senderId) &&
|
||||
Objects.equals(application, concrete.application) &&
|
||||
Objects.equals(emojis, concrete.emojis) &&
|
||||
Objects.equals(card, concrete.card);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Placeholder extends StatusViewData {
|
||||
private final boolean isLoading;
|
||||
private final long id;
|
||||
|
||||
public Placeholder(boolean isLoading) {
|
||||
public Placeholder(long id, boolean isLoading) {
|
||||
this.id = id;
|
||||
this.isLoading = isLoading;
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override public long getViewDataId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override public boolean deepEquals(StatusViewData other) {
|
||||
if (!(other instanceof Placeholder)) return false;
|
||||
Placeholder that = (Placeholder) other;
|
||||
return isLoading == that.isLoading && id == that.id;
|
||||
}
|
||||
|
||||
@Override public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Placeholder that = (Placeholder) o;
|
||||
|
||||
return deepEquals(that);
|
||||
}
|
||||
|
||||
@Override public int hashCode() {
|
||||
int result = (isLoading ? 1 : 0);
|
||||
result = 31 * result + (int) (id ^ (id >>> 32));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
|
|
@ -1,12 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.support.v4.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="top">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.support.v7.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</android.support.v4.widget.SwipeRefreshLayout>
|
||||
|
||||
</android.support.v4.widget.SwipeRefreshLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/nothing_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="16dp"
|
||||
android:text="@string/footer_empty"
|
||||
android:textAlignment="center"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</android.support.constraint.ConstraintLayout>
|
|
@ -1,5 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Button xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_load_more"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -7,3 +12,13 @@
|
|||
android:text="@string/load_more_placeholder_text"
|
||||
android:textColor="?attr/colorAccent"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
android:layout_margin="8dp"/>
|
||||
</FrameLayout>
|
|
@ -20,6 +20,8 @@ import android.text.SpannedString
|
|||
import android.widget.EditText
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceDao
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
|
@ -33,6 +35,7 @@ import org.junit.Test
|
|||
import org.junit.runner.RunWith
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
@ -50,8 +53,6 @@ import retrofit2.Response
|
|||
class ComposeActivityTest {
|
||||
|
||||
lateinit var activity: ComposeActivity
|
||||
lateinit var application: FakeTuskyApplication
|
||||
lateinit var serviceLocator: TuskyApplication.ServiceLocator
|
||||
lateinit var accountManagerMock: AccountManager
|
||||
lateinit var apiMock: MastodonApi
|
||||
|
||||
|
@ -81,9 +82,6 @@ class ComposeActivityTest {
|
|||
activity = controller.get()
|
||||
|
||||
accountManagerMock = Mockito.mock(AccountManager::class.java)
|
||||
serviceLocator = Mockito.mock(TuskyApplication.ServiceLocator::class.java)
|
||||
`when`(serviceLocator.get(AccountManager::class.java)).thenReturn(accountManagerMock)
|
||||
`when`(accountManagerMock.activeAccount).thenReturn(account)
|
||||
|
||||
apiMock = Mockito.mock(MastodonApi::class.java)
|
||||
`when`(apiMock.customEmojis).thenReturn(object: Call<List<Emoji>> {
|
||||
|
@ -133,10 +131,13 @@ class ComposeActivityTest {
|
|||
}
|
||||
})
|
||||
|
||||
val instanceDaoMock = mock(InstanceDao::class.java)
|
||||
val dbMock = mock(AppDatabase::class.java)
|
||||
`when`(dbMock.instanceDao()).thenReturn(instanceDaoMock)
|
||||
|
||||
activity.mastodonApi = apiMock
|
||||
activity.accountManager = accountManagerMock
|
||||
application = activity.application as FakeTuskyApplication
|
||||
application.locator = serviceLocator
|
||||
activity.database = dbMock
|
||||
|
||||
`when`(accountManagerMock.activeAccount).thenReturn(account)
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.keylesspalace.tusky.di
|
||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,4 +1,4 @@
|
|||
#Wed Oct 25 20:21:12 CEST 2017
|
||||
#Fri Apr 06 21:32:27 MSK 2018
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
Loading…
Reference in a new issue