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:
Ivan Kupalov 2018-05-27 11:22:12 +03:00 committed by GitHub
parent 3a8d96346b
commit 3756a1fd20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1064 additions and 542 deletions

View file

@ -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'
}

View file

@ -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 {

View file

@ -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

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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());
}
});
}
}

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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()
}

View file

@ -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 {

View file

@ -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);
}
};

View file

@ -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

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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);
}
};
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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))
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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);
}

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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)

View file

@ -0,0 +1,2 @@
package com.keylesspalace.tusky

View file

@ -0,0 +1,2 @@
package com.keylesspalace.tusky.di

View file

@ -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