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' 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.ImageView;
import android.widget.TextView; 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.db.AccountEntity;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Relationship; import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.pager.AccountPagerAdapter; import com.keylesspalace.tusky.pager.AccountPagerAdapter;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.Assert; import com.keylesspalace.tusky.util.Assert;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
@ -86,6 +89,8 @@ public final class AccountActivity extends BottomSheetActivity implements Action
@Inject @Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector; public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
@Inject
public EventHub appstore;
private String accountId; private String accountId;
private FollowState followState; private FollowState followState;
@ -524,7 +529,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action
Snackbar.LENGTH_LONG).show(); Snackbar.LENGTH_LONG).show();
} else { } else {
followState = FollowState.NOT_FOLLOWING; followState = FollowState.NOT_FOLLOWING;
broadcast(TimelineReceiver.Types.UNFOLLOW_ACCOUNT, id); appstore.dispatch(new UnfollowEvent(id));
} }
updateButtons(); updateButtons();
} else { } else {
@ -581,7 +586,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action
@NonNull Response<Relationship> response) { @NonNull Response<Relationship> response) {
Relationship relationship = response.body(); Relationship relationship = response.body();
if (response.isSuccessful() && relationship != null) { if (response.isSuccessful() && relationship != null) {
broadcast(TimelineReceiver.Types.BLOCK_ACCOUNT, id); appstore.dispatch(new BlockEvent(id));
blocking = relationship.getBlocking(); blocking = relationship.getBlocking();
updateButtons(); updateButtons();
} else { } else {
@ -615,7 +620,7 @@ public final class AccountActivity extends BottomSheetActivity implements Action
@NonNull Response<Relationship> response) { @NonNull Response<Relationship> response) {
Relationship relationship = response.body(); Relationship relationship = response.body();
if (response.isSuccessful() && relationship != null) { if (response.isSuccessful() && relationship != null) {
broadcast(TimelineReceiver.Types.MUTE_ACCOUNT, id); appstore.dispatch(new MuteEvent(id));
muting = relationship.getMuting(); muting = relationship.getMuting();
updateButtons(); updateButtons();
} else { } else {

View File

@ -85,6 +85,8 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter;
import com.keylesspalace.tusky.adapter.MentionAutoCompleteAdapter; import com.keylesspalace.tusky.adapter.MentionAutoCompleteAdapter;
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener; import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener;
import com.keylesspalace.tusky.db.AccountEntity; 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.db.InstanceEntity;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Account;
@ -169,6 +171,8 @@ public final class ComposeActivity
@Inject @Inject
public MastodonApi mastodonApi; public MastodonApi mastodonApi;
@Inject
public AppDatabase database;
private TextView replyTextView; private TextView replyTextView;
private TextView replyContentTextView; private TextView replyContentTextView;
@ -230,7 +234,7 @@ public final class ComposeActivity
emojiView = findViewById(R.id.emojiView); emojiView = findViewById(R.id.emojiView);
emojiList = Collections.emptyList(); emojiList = Collections.emptyList();
saveTootHelper = new SaveTootHelper(TuskyApplication.getDB().tootDao(), this); saveTootHelper = new SaveTootHelper(database.tootDao(), this);
// Setup the toolbar. // Setup the toolbar.
Toolbar toolbar = findViewById(R.id.toolbar); Toolbar toolbar = findViewById(R.id.toolbar);
@ -1454,7 +1458,8 @@ public final class ComposeActivity
} }
private void loadCachedInstanceMetadata(@NotNull AccountEntity activeAccount) { private void loadCachedInstanceMetadata(@NotNull AccountEntity activeAccount) {
InstanceEntity instanceEntity = TuskyApplication.getDB().instanceDao().loadMetadataForInstance(activeAccount.getDomain()); InstanceEntity instanceEntity = database.instanceDao()
.loadMetadataForInstance(activeAccount.getDomain());
if(instanceEntity != null) { if(instanceEntity != null) {
Integer max = instanceEntity.getMaximumTootCharacters(); Integer max = instanceEntity.getMaximumTootCharacters();
@ -1474,7 +1479,7 @@ public final class ComposeActivity
private void cacheInstanceMetadata(@NotNull AccountEntity activeAccount) { private void cacheInstanceMetadata(@NotNull AccountEntity activeAccount) {
InstanceEntity instanceEntity = new InstanceEntity(activeAccount.getDomain(), emojiList, maximumTootCharacters); InstanceEntity instanceEntity = new InstanceEntity(activeAccount.getDomain(), emojiList, maximumTootCharacters);
TuskyApplication.getDB().instanceDao().insertOrReplace(instanceEntity); database.instanceDao().insertOrReplace(instanceEntity);
} }
// Accessors for testing, hence package scope // Accessors for testing, hence package scope

View File

@ -70,7 +70,7 @@ import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
public class MainActivity extends BottomSheetActivity implements ActionButtonActivity, public final class MainActivity extends BottomSheetActivity implements ActionButtonActivity,
HasSupportFragmentInjector { HasSupportFragmentInjector {
private static final String TAG = "MainActivity"; // logging tag private static final String TAG = "MainActivity"; // logging tag
private static final long DRAWER_ITEM_ADD_ACCOUNT = -13; private static final long DRAWER_ITEM_ADD_ACCOUNT = -13;

View File

@ -15,15 +15,12 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import android.content.BroadcastReceiver; import android.arch.lifecycle.Lifecycle;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.ActionBar; import android.support.v7.app.ActionBar;
import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
@ -34,9 +31,12 @@ import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import com.keylesspalace.tusky.adapter.SavedTootAdapter; 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.TootDao;
import com.keylesspalace.tusky.db.TootEntity; 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.SaveTootHelper;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
@ -44,10 +44,15 @@ import java.lang.ref.WeakReference;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction { import javax.inject.Inject;
// dao import io.reactivex.android.schedulers.AndroidSchedulers;
private static TootDao tootDao = TuskyApplication.getDB().tootDao();
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; private SaveTootHelper saveTootHelper;
@ -56,28 +61,25 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
private TextView noContent; private TextView noContent;
private List<TootEntity> toots = new ArrayList<>(); private List<TootEntity> toots = new ArrayList<>();
@Nullable private AsyncTask<?, ?, ?> asyncTask; @Nullable
private AsyncTask<?, ?, ?> asyncTask;
private BroadcastReceiver broadcastReceiver; @Inject
EventHub eventHub;
@Inject
AppDatabase database;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
saveTootHelper = new SaveTootHelper(tootDao, this); saveTootHelper = new SaveTootHelper(database.tootDao(), this);
broadcastReceiver = new BroadcastReceiver() { eventHub.getEvents()
@Override .observeOn(AndroidSchedulers.mainThread())
public void onReceive(Context context, Intent intent) { .ofType(StatusComposedEvent.class)
fetchToots(); .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
} .subscribe((__) -> this.fetchToots());
};
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(TimelineReceiver.Types.STATUS_COMPOSED);
LocalBroadcastManager.getInstance(this)
.registerReceiver(broadcastReceiver, intentFilter);
setContentView(R.layout.activity_saved_toot); setContentView(R.layout.activity_saved_toot);
@ -117,12 +119,6 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
if (asyncTask != null) asyncTask.cancel(true); if (asyncTask != null) asyncTask.cancel(true);
} }
@Override
protected void onDestroy() {
super.onDestroy();
LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver);
}
@Override @Override
public boolean onOptionsItemSelected(MenuItem item) { public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
@ -135,7 +131,7 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
} }
private void fetchToots() { private void fetchToots() {
asyncTask = new FetchPojosTask(this) asyncTask = new FetchPojosTask(this, database.tootDao())
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); .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>> { static final class FetchPojosTask extends AsyncTask<Void, Void, List<TootEntity>> {
private final WeakReference<SavedTootActivity> activityRef; private final WeakReference<SavedTootActivity> activityRef;
private final TootDao tootDao;
FetchPojosTask(SavedTootActivity activity) { FetchPojosTask(SavedTootActivity activity, TootDao tootDao) {
this.activityRef = new WeakReference<>(activity); this.activityRef = new WeakReference<>(activity);
this.tootDao = tootDao;
} }
@Override @Override
@ -194,13 +192,12 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
SavedTootActivity activity = activityRef.get(); SavedTootActivity activity = activityRef.get();
if (activity == null) return; if (activity == null) return;
activity.toots.clear();
activity.toots.addAll(pojos); activity.toots.addAll(pojos);
// set ui // set ui
activity.setNoContent(pojos.size()); activity.setNoContent(pojos.size());
List<TootEntity> toots = new ArrayList<>(pojos.size()); activity.adapter.setItems(activity.toots);
toots.addAll(pojos);
activity.adapter.setItems(toots);
activity.adapter.notifyDataSetChanged(); activity.adapter.notifyDataSetChanged();
} }
} }

View File

@ -18,30 +18,35 @@ package com.keylesspalace.tusky.adapter;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.view.View; import android.view.View;
import android.widget.Button; import android.widget.Button;
import android.widget.ProgressBar;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
public class PlaceholderViewHolder extends RecyclerView.ViewHolder { public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
private Button loadMoreButton; private Button loadMoreButton;
private ProgressBar progressBar;
PlaceholderViewHolder(View itemView) { PlaceholderViewHolder(View itemView) {
super(itemView); super(itemView);
loadMoreButton = itemView.findViewById(R.id.button_load_more); 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); loadMoreButton.setEnabled(enabled);
if(enabled) { if (enabled) {
loadMoreButton.setOnClickListener(new View.OnClickListener() { loadMoreButton.setOnClickListener(v -> {
@Override loadMoreButton.setEnabled(false);
public void onClick(View v) { listener.onLoadMore(getAdapterPosition());
loadMoreButton.setEnabled(false);
listener.onLoadMore(getAdapterPosition());
}
}); });
} }
} }

View File

@ -118,6 +118,11 @@ public class ThreadAdapter extends RecyclerView.Adapter {
notifyItemRangeInserted(end, statuses.size()); notifyItemRangeInserted(end, statuses.size());
} }
public void removeItem(int position) {
statuses.remove(position);
notifyItemRemoved(position);
}
public void clear() { public void clear() {
statuses.clear(); statuses.clear();
detailedStatusPosition = RecyclerView.NO_POSITION; detailedStatusPosition = RecyclerView.NO_POSITION;

View File

@ -15,7 +15,7 @@
package com.keylesspalace.tusky.adapter; package com.keylesspalace.tusky.adapter;
import android.support.annotation.Nullable; import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -25,29 +25,32 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.ArrayList; public final class TimelineAdapter extends RecyclerView.Adapter {
import java.util.List;
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_STATUS = 0;
private static final int VIEW_TYPE_FOOTER = 1;
private static final int VIEW_TYPE_PLACEHOLDER = 2; private static final int VIEW_TYPE_PLACEHOLDER = 2;
private List<StatusViewData> statuses; private final AdapterDataSource<StatusViewData> dataSource;
private StatusActionListener statusListener; private final StatusActionListener statusListener;
private FooterViewHolder.State footerState;
private boolean mediaPreviewEnabled; private boolean mediaPreviewEnabled;
public TimelineAdapter(StatusActionListener statusListener) { public TimelineAdapter(AdapterDataSource<StatusViewData> dataSource,
StatusActionListener statusListener) {
super(); super();
statuses = new ArrayList<>(); this.dataSource = dataSource;
this.statusListener = statusListener; this.statusListener = statusListener;
footerState = FooterViewHolder.State.END;
mediaPreviewEnabled = true; mediaPreviewEnabled = true;
} }
@NonNull
@Override @Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
switch (viewType) { switch (viewType) {
default: default:
case VIEW_TYPE_STATUS: { case VIEW_TYPE_STATUS: {
@ -55,11 +58,6 @@ public class TimelineAdapter extends RecyclerView.Adapter {
.inflate(R.layout.item_status, viewGroup, false); .inflate(R.layout.item_status, viewGroup, false);
return new StatusViewHolder(view); 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: { case VIEW_TYPE_PLACEHOLDER: {
View view = LayoutInflater.from(viewGroup.getContext()) View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_status_placeholder, viewGroup, false); .inflate(R.layout.item_status_placeholder, viewGroup, false);
@ -69,76 +67,39 @@ public class TimelineAdapter extends RecyclerView.Adapter {
} }
@Override @Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (position < statuses.size()) { StatusViewData status = dataSource.getItemAt(position);
StatusViewData status = statuses.get(position); if (status instanceof StatusViewData.Placeholder) {
if (status instanceof StatusViewData.Placeholder) { PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; holder.setup(!((StatusViewData.Placeholder) status).isLoading(),
holder.setup(!((StatusViewData.Placeholder) status).isLoading(), statusListener); statusListener, ((StatusViewData.Placeholder) status).isLoading());
} else {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
holder.setupWithStatus((StatusViewData.Concrete) status,
statusListener, mediaPreviewEnabled);
}
} else { } else {
FooterViewHolder holder = (FooterViewHolder) viewHolder; StatusViewHolder holder = (StatusViewHolder) viewHolder;
holder.setState(footerState); holder.setupWithStatus((StatusViewData.Concrete) status,
statusListener, mediaPreviewEnabled);
} }
} }
@Override @Override
public int getItemCount() { public int getItemCount() {
return statuses.size() + 1; return dataSource.getItemCount();
} }
@Override @Override
public int getItemViewType(int position) { public int getItemViewType(int position) {
if (position == statuses.size()) { if (dataSource.getItemAt(position) instanceof StatusViewData.Placeholder) {
return VIEW_TYPE_FOOTER; return VIEW_TYPE_PLACEHOLDER;
} else { } else {
if (statuses.get(position) instanceof StatusViewData.Placeholder) { return VIEW_TYPE_STATUS;
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) { public void setMediaPreviewEnabled(boolean enabled) {
mediaPreviewEnabled = 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.preference.PreferenceManager
import android.support.v4.content.LocalBroadcastManager import android.support.v4.content.LocalBroadcastManager
import com.keylesspalace.tusky.TuskyApplication 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.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.network.TimelineCasesImpl import com.keylesspalace.tusky.network.TimelineCasesImpl
@ -55,8 +58,8 @@ class AppModule {
@Provides @Provides
fun providesTimelineUseCases(api: MastodonApi, fun providesTimelineUseCases(api: MastodonApi,
broadcastManager: LocalBroadcastManager): TimelineCases { eventHub: EventHub): TimelineCases {
return TimelineCasesImpl(api, broadcastManager) return TimelineCasesImpl(api, eventHub)
} }
@Provides @Provides
@ -64,4 +67,12 @@ class AppModule {
fun providesAccountManager(app: TuskyApplication): AccountManager { fun providesAccountManager(app: TuskyApplication): AccountManager {
return app.serviceLocator.get(AccountManager::class.java) 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") @SerializedName("username")
var localUsername: String? = null 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 { 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. // Just use the basic scroll listener to load more accounts.
scrollListener = new EndlessOnScrollListener(layoutManager) { scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override @Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { public void onLoadMore(int totalItemsCount, RecyclerView view) {
AccountListFragment.this.onLoadMore(view); AccountListFragment.this.onLoadMore(view);
} }
}; };

View File

@ -16,6 +16,8 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.app.Fragment; import android.support.v4.app.Fragment;
@ -26,11 +28,21 @@ import retrofit2.Call;
public class BaseFragment extends Fragment { public class BaseFragment extends Fragment {
protected List<Call> callList; protected List<Call> callList;
private final Handler handler = new Handler(Looper.getMainLooper());
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
callList = new ArrayList<>(); 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 @Override

View File

@ -17,6 +17,7 @@ package com.keylesspalace.tusky.fragment;
import android.app.Activity; import android.app.Activity;
import android.arch.core.util.Function; import android.arch.core.util.Function;
import android.arch.lifecycle.Lifecycle;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
@ -26,30 +27,36 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.design.widget.FloatingActionButton; import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.TabLayout; 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.v4.widget.SwipeRefreshLayout;
import android.support.v7.content.res.AppCompatResources;
import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SimpleItemAnimator;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.FooterViewHolder; import com.keylesspalace.tusky.adapter.FooterViewHolder;
import com.keylesspalace.tusky.adapter.NotificationsAdapter; 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.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.CollectionUtil; import com.keylesspalace.tusky.util.CollectionUtil;
import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.Either;
import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.HttpHeaderLink;
@ -64,13 +71,18 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Objects;
import javax.inject.Inject; import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; 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 public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
StatusActionListener, StatusActionListener,
@ -106,15 +118,19 @@ public class NotificationsFragment extends SFragment implements
public TimelineCases timelineCases; public TimelineCases timelineCases;
@Inject @Inject
AccountManager accountManager; AccountManager accountManager;
@Inject
EventHub eventHub;
private SwipeRefreshLayout swipeRefreshLayout; private SwipeRefreshLayout swipeRefreshLayout;
private LinearLayoutManager layoutManager;
private RecyclerView recyclerView; private RecyclerView recyclerView;
private ProgressBar progressBar;
private TextView nothingMessageView;
private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener; private EndlessOnScrollListener scrollListener;
private NotificationsAdapter adapter; private NotificationsAdapter adapter;
private TabLayout.OnTabSelectedListener onTabSelectedListener; private TabLayout.OnTabSelectedListener onTabSelectedListener;
private boolean hideFab; private boolean hideFab;
private TimelineReceiver timelineReceiver;
private boolean topLoading; private boolean topLoading;
private int topFetches; private int topFetches;
private boolean bottomLoading; private boolean bottomLoading;
@ -158,11 +174,14 @@ public class NotificationsFragment extends SFragment implements
@NonNull Context context = inflater.getContext(); // from inflater to silence warning @NonNull Context context = inflater.getContext(); // from inflater to silence warning
// Setup the SwipeRefreshLayout. // Setup the SwipeRefreshLayout.
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout); 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.setOnRefreshListener(this);
swipeRefreshLayout.setColorSchemeResources(R.color.primary); swipeRefreshLayout.setColorSchemeResources(R.color.primary);
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)); swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground));
// Setup the RecyclerView. // Setup the RecyclerView.
recyclerView = rootView.findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true); recyclerView.setHasFixedSize(true);
layoutManager = new LinearLayoutManager(context); layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager); recyclerView.setLayoutManager(layoutManager);
@ -181,10 +200,6 @@ public class NotificationsFragment extends SFragment implements
adapter.setMediaPreviewEnabled(mediaPreviewEnabled); adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
recyclerView.setAdapter(adapter); recyclerView.setAdapter(adapter);
timelineReceiver = new TimelineReceiver(this);
LocalBroadcastManager.getInstance(context.getApplicationContext())
.registerReceiver(timelineReceiver, TimelineReceiver.getFilter(null));
notifications.clear(); notifications.clear();
topLoading = false; topLoading = false;
topFetches = 0; topFetches = 0;
@ -193,9 +208,58 @@ public class NotificationsFragment extends SFragment implements
bottomId = null; bottomId = null;
topId = null; topId = null;
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
setupNothingView();
return rootView; 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 @Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) { public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState); super.onActivityCreated(savedInstanceState);
@ -250,7 +314,7 @@ public class NotificationsFragment extends SFragment implements
} }
@Override @Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { public void onLoadMore(int totalItemsCount, RecyclerView view) {
NotificationsFragment.this.onLoadMore(); NotificationsFragment.this.onLoadMore();
} }
}; };
@ -266,9 +330,6 @@ public class NotificationsFragment extends SFragment implements
} else { } else {
TabLayout tabLayout = activity.findViewById(R.id.tab_layout); TabLayout tabLayout = activity.findViewById(R.id.tab_layout);
tabLayout.removeOnTabSelectedListener(onTabSelectedListener); tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
LocalBroadcastManager.getInstance(activity)
.unregisterReceiver(timelineReceiver);
} }
super.onDestroyView(); super.onDestroyView();
@ -292,24 +353,7 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.setReblogged(reblog); setReblogForStatus(position, status, reblog);
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
viewDataBuilder.setReblogged(reblog);
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
viewDataBuilder.createStatusViewData(), viewdata.isExpanded());
notifications.setPairedItem(position, newViewData);
adapter.updateItemWithNotify(position, newViewData, false);
} }
} }
@ -320,6 +364,27 @@ public class NotificationsFragment extends SFragment implements
}); });
} }
private void setReblogForStatus(int position, Status status, boolean reblog) {
status.setReblogged(reblog);
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
viewDataBuilder.setReblogged(reblog);
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
viewDataBuilder.createStatusViewData(), viewdata.isExpanded());
notifications.setPairedItem(position, newViewData);
adapter.updateItemWithNotify(position, newViewData, true);
}
@Override @Override
public void onFavourite(final boolean favourite, final int position) { public void onFavourite(final boolean favourite, final int position) {
@ -329,24 +394,7 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull retrofit2.Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.setFavourited(favourite); setFavovouriteForStatus(position, status, favourite);
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
viewDataBuilder.setFavourited(favourite);
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
viewDataBuilder.createStatusViewData(), viewdata.isExpanded());
notifications.setPairedItem(position, newViewData);
adapter.updateItemWithNotify(position, newViewData, false);
} }
} }
@ -358,6 +406,27 @@ public class NotificationsFragment extends SFragment implements
}); });
} }
private void setFavovouriteForStatus(int position, Status status, boolean favourite) {
status.setFavourited(favourite);
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData());
viewDataBuilder.setFavourited(favourite);
NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete(
viewdata.getType(), viewdata.getId(), viewdata.getAccount(),
viewDataBuilder.createStatusViewData(), viewdata.isExpanded());
notifications.setPairedItem(position, newViewData);
adapter.updateItemWithNotify(position, newViewData, true);
}
@Override @Override
public void onMore(View view, int position) { public void onMore(View view, int position) {
Notification notification = notifications.get(position).getAsRight(); Notification notification = notifications.get(position).getAsRight();
@ -475,8 +544,7 @@ public class NotificationsFragment extends SFragment implements
adapter.update(notifications.getPairedCopy()); adapter.update(notifications.getPairedCopy());
} }
@Override private void removeAllByAccountId(String accountId) {
public void removeAllByAccountId(String accountId) {
// using iterator to safely remove items while iterating // using iterator to safely remove items while iterating
Iterator<Either<Placeholder, Notification>> iterator = notifications.iterator(); Iterator<Either<Placeholder, Notification>> iterator = notifications.iterator();
while (iterator.hasNext()) { while (iterator.hasNext()) {
@ -590,6 +658,7 @@ public class NotificationsFragment extends SFragment implements
adapter.setFooterState(FooterViewHolder.State.END); adapter.setFooterState(FooterViewHolder.State.END);
} }
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
progressBar.setVisibility(View.GONE);
} }
private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) { 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()); Log.e(TAG, "Fetch failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd); fulfillAnyQueuedFetches(fetchEnd);
progressBar.setVisibility(View.GONE);
} }
private void saveNewestNotificationId(List<Notification> notifications) { private void saveNewestNotificationId(List<Notification> notifications) {
@ -623,7 +693,6 @@ public class NotificationsFragment extends SFragment implements
} }
private boolean isBiggerThan(BigInteger newId, BigInteger lastShownNotificationId) { private boolean isBiggerThan(BigInteger newId, BigInteger lastShownNotificationId) {
return lastShownNotificationId.compareTo(newId) < 0; return lastShownNotificationId.compareTo(newId) < 0;
} }
@ -736,4 +805,20 @@ public class NotificationsFragment extends SFragment implements
notifications.clear(); notifications.clear();
sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); 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.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.design.widget.BottomSheetBehavior;
import android.support.v4.app.ActivityOptionsCompat; import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.view.ViewCompat; import android.support.v4.view.ViewCompat;
import android.support.v7.widget.PopupMenu; 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.db.AccountManager;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.AdapterItemRemover;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.HtmlUtils; 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 * 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 * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */ * 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 static final int COMPOSE_RESULT = 1;
protected String loggedInAccountId; protected String loggedInAccountId;
protected String loggedInUsername; protected String loggedInUsername;
protected abstract TimelineCases timelineCases(); protected abstract TimelineCases timelineCases();
protected abstract void removeItem(int position);
private BottomSheetActivity bottomSheetActivity; private BottomSheetActivity bottomSheetActivity;
@Inject @Inject
protected MastodonApi mastodonApi; public MastodonApi mastodonApi;
@Override @Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) { public void onActivityCreated(@Nullable Bundle savedInstanceState) {

View File

@ -126,10 +126,6 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable {
searchAdapter.removeStatusAtPosition(position) searchAdapter.removeStatusAtPosition(position)
} }
override fun removeAllByAccountId(accountId: String?) {
// not supported
}
override fun onReply(position: Int) { override fun onReply(position: Int) {
val status = searchAdapter.getStatusAtPosition(position) val status = searchAdapter.getStatusAtPosition(position)
if(status != null) { if(status != null) {

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import android.arch.core.util.Function; import android.arch.core.util.Function;
import android.arch.lifecycle.Lifecycle;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
@ -25,29 +26,40 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.design.widget.FloatingActionButton; import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.TabLayout; import android.support.design.widget.TabLayout;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.util.Pair; import android.support.v4.util.Pair;
import android.support.v4.widget.SwipeRefreshLayout; 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.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SimpleItemAnimator;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; 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.R;
import com.keylesspalace.tusky.adapter.FooterViewHolder;
import com.keylesspalace.tusky.adapter.TimelineAdapter; 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.di.Injectable;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.CollectionUtil; import com.keylesspalace.tusky.util.CollectionUtil;
import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.Either;
import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.HttpHeaderLink;
@ -60,16 +72,20 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Objects;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javax.inject.Inject; import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; 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 public class TimelineFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
StatusActionListener, StatusActionListener,
@ -98,13 +114,18 @@ public class TimelineFragment extends SFragment implements
} }
@Inject @Inject
TimelineCases timelineCases; public TimelineCases timelineCases;
@Inject
public EventHub eventHub;
private SwipeRefreshLayout swipeRefreshLayout; private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ProgressBar progressBar;
private TextView nothingMessageView;
private TimelineAdapter adapter; private TimelineAdapter adapter;
private Kind kind; private Kind kind;
private String hashtagOrId; private String hashtagOrId;
private RecyclerView recyclerView;
private LinearLayoutManager layoutManager; private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener; private EndlessOnScrollListener scrollListener;
private TabLayout.OnTabSelectedListener onTabSelectedListener; private TabLayout.OnTabSelectedListener onTabSelectedListener;
@ -113,15 +134,16 @@ public class TimelineFragment extends SFragment implements
private boolean filterRemoveRegex; private boolean filterRemoveRegex;
private Matcher filterRemoveRegexMatcher; private Matcher filterRemoveRegexMatcher;
private boolean hideFab; private boolean hideFab;
private TimelineReceiver timelineReceiver;
private boolean topLoading; private boolean topLoading;
private int topFetches; private int topFetches;
private boolean bottomLoading; private boolean bottomLoading;
private int bottomFetches;
@Nullable @Nullable
private String bottomId; private String bottomId;
@Nullable @Nullable
private String topId; private String topId;
private long maxPlaceholderId = -1;
private boolean didLoadEverythingBottom;
private boolean alwaysShowSensitiveMedia; private boolean alwaysShowSensitiveMedia;
@ -138,7 +160,8 @@ public class TimelineFragment extends SFragment implements
if (status != null) { if (status != null) {
return ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia); return ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia);
} else { } 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 static final class Placeholder {
private final static Placeholder INSTANCE = new Placeholder(); final long id;
public static Placeholder getInstance() { public static Placeholder getInstance(long id) {
return INSTANCE; return new Placeholder(id);
} }
private Placeholder() { private Placeholder(long id) {
this.id = id;
} }
} }
@Override @Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) { Bundle savedInstanceState) {
Bundle arguments = getArguments(); Bundle arguments = Objects.requireNonNull(getArguments());
kind = Kind.valueOf(arguments.getString(KIND_ARG)); kind = Kind.valueOf(arguments.getString(KIND_ARG));
if (kind == Kind.TAG || kind == Kind.USER || kind == Kind.LIST) { if (kind == Kind.TAG || kind == Kind.USER || kind == Kind.LIST) {
hashtagOrId = arguments.getString(HASHTAG_OR_ID_ARG); 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); final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
// Setup the SwipeRefreshLayout. recyclerView = rootView.findViewById(R.id.recycler_view);
Context context = getContext();
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout); 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.setOnRefreshListener(this);
swipeRefreshLayout.setColorSchemeResources(R.color.primary); swipeRefreshLayout.setColorSchemeResources(R.color.primary);
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)); swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context,
// Setup the RecyclerView. android.R.attr.colorBackground));
recyclerView = rootView.findViewById(R.id.recycler_view); }
private void setupRecyclerView() {
Context context = Objects.requireNonNull(getContext());
recyclerView.setHasFixedSize(true); recyclerView.setHasFixedSize(true);
layoutManager = new LinearLayoutManager(context); layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager); recyclerView.setLayoutManager(layoutManager);
@ -199,38 +282,58 @@ public class TimelineFragment extends SFragment implements
R.drawable.status_divider_dark); R.drawable.status_divider_dark);
divider.setDrawable(drawable); divider.setDrawable(drawable);
recyclerView.addItemDecoration(divider); recyclerView.addItemDecoration(divider);
adapter = new TimelineAdapter(this);
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( // CWs are expanded without animation, buttons animate itself, we don't need it basically
getActivity()); ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
preferences.registerOnSharedPreferenceChangeListener(this);
alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false);
boolean mediaPreviewEnabled = preferences.getBoolean("mediaPreviewEnabled", true);
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
recyclerView.setAdapter(adapter); recyclerView.setAdapter(adapter);
}
boolean filter = preferences.getBoolean("tabFilterHomeReplies", true); @Override
filterRemoveReplies = kind == Kind.HOME && !filter; public void onPostCreate() {
super.onPostCreate();
filter = preferences.getBoolean("tabFilterHomeBoosts", true); eventHub.getEvents()
filterRemoveReblogs = kind == Kind.HOME && !filter; .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", ""); private void deleteStatusById(String id) {
filterRemoveRegex = (kind == Kind.HOME || kind == Kind.PUBLIC_LOCAL || kind == Kind.PUBLIC_FEDERATED) && !regexFilter.isEmpty(); for (int i = 0; i < statuses.size(); i++) {
if (filterRemoveRegex) filterRemoveRegexMatcher = Pattern.compile(regexFilter, Pattern.CASE_INSENSITIVE).matcher(""); Either<Placeholder, Status> either = statuses.get(i);
if (either.isRight()
timelineReceiver = new TimelineReceiver(this, this); && id.equals(either.getAsRight().getId())) {
LocalBroadcastManager.getInstance(context.getApplicationContext()) statuses.remove(either);
.registerReceiver(timelineReceiver, TimelineReceiver.getFilter(kind)); updateAdapter();
break;
statuses.clear(); }
topLoading = false; }
topFetches = 0;
bottomLoading = false;
bottomFetches = 0;
bottomId = null;
topId = null;
return rootView;
} }
@Override @Override
@ -238,7 +341,7 @@ public class TimelineFragment extends SFragment implements
super.onActivityCreated(savedInstanceState); super.onActivityCreated(savedInstanceState);
if (jumpToTopAllowed()) { if (jumpToTopAllowed()) {
TabLayout layout = getActivity().findViewById(R.id.tab_layout); TabLayout layout = Objects.requireNonNull(getActivity()).findViewById(R.id.tab_layout);
if (layout != null) { if (layout != null) {
onTabSelectedListener = new TabLayout.OnTabSelectedListener() { onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@Override @Override
@ -287,7 +390,7 @@ public class TimelineFragment extends SFragment implements
} }
@Override @Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { public void onLoadMore(int totalItemsCount, RecyclerView view) {
TimelineFragment.this.onLoadMore(); TimelineFragment.this.onLoadMore();
} }
}; };
@ -295,7 +398,7 @@ public class TimelineFragment extends SFragment implements
// Just use the basic scroll listener to load more statuses. // Just use the basic scroll listener to load more statuses.
scrollListener = new EndlessOnScrollListener(layoutManager) { scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override @Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { public void onLoadMore(int totalItemsCount, RecyclerView view) {
TimelineFragment.this.onLoadMore(); TimelineFragment.this.onLoadMore();
} }
}; };
@ -306,15 +409,25 @@ public class TimelineFragment extends SFragment implements
@Override @Override
public void onDestroyView() { public void onDestroyView() {
if (jumpToTopAllowed()) { if (jumpToTopAllowed()) {
TabLayout tabLayout = getActivity().findViewById(R.id.tab_layout); TabLayout tabLayout = Objects.requireNonNull(getActivity())
.findViewById(R.id.tab_layout);
if (tabLayout != null) { if (tabLayout != null) {
tabLayout.removeOnTabSelectedListener(onTabSelectedListener); tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
} }
} }
LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(timelineReceiver);
super.onDestroyView(); 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 @Override
public void onRefresh() { public void onRefresh() {
sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1); sendFetchTimelineRequest(null, topId, FetchEnd.TOP, -1);
@ -333,22 +446,7 @@ public class TimelineFragment extends SFragment implements
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.setReblogged(reblog); setRebloggedForStatus(position, status, reblog);
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData =
new StatusViewData.Builder(actual.first)
.setReblogged(reblog)
.createStatusViewData();
statuses.setPairedItem(actual.second, newViewData);
adapter.changeItem(actual.second, newViewData, false);
} }
} }
@ -359,6 +457,25 @@ public class TimelineFragment extends SFragment implements
}); });
} }
private void setRebloggedForStatus(int position, Status status, boolean reblog) {
status.setReblogged(reblog);
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData =
new StatusViewData.Builder(actual.first)
.setReblogged(reblog)
.createStatusViewData();
statuses.setPairedItem(actual.second, newViewData);
updateAdapter();
}
@Override @Override
public void onFavourite(final boolean favourite, final int position) { public void onFavourite(final boolean favourite, final int position) {
final Status status = statuses.get(position).getAsRight(); final Status status = statuses.get(position).getAsRight();
@ -368,22 +485,7 @@ public class TimelineFragment extends SFragment implements
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.setFavourited(favourite); setFavouriteForStatus(position, status, favourite);
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData = new StatusViewData
.Builder(actual.first)
.setFavourited(favourite)
.createStatusViewData();
statuses.setPairedItem(actual.second, newViewData);
adapter.changeItem(actual.second, newViewData, false);
} }
} }
@ -394,6 +496,25 @@ public class TimelineFragment extends SFragment implements
}); });
} }
private void setFavouriteForStatus(int position, Status status, boolean favourite) {
status.setFavourited(favourite);
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
Pair<StatusViewData.Concrete, Integer> actual =
findStatusAndPosition(position, status);
if (actual == null) return;
StatusViewData newViewData = new StatusViewData
.Builder(actual.first)
.setFavourited(favourite)
.createStatusViewData();
statuses.setPairedItem(actual.second, newViewData);
updateAdapter();
}
@Override @Override
public void onMore(View view, final int position) { public void onMore(View view, final int position) {
super.more(statuses.get(position).getAsRight(), view, position); super.more(statuses.get(position).getAsRight(), view, position);
@ -410,7 +531,7 @@ public class TimelineFragment extends SFragment implements
((StatusViewData.Concrete) statuses.getPairedItem(position))) ((StatusViewData.Concrete) statuses.getPairedItem(position)))
.setIsExpanded(expanded).createStatusViewData(); .setIsExpanded(expanded).createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(position, newViewData);
adapter.changeItem(position, newViewData, false); updateAdapter();
} }
@Override @Override
@ -419,7 +540,7 @@ public class TimelineFragment extends SFragment implements
((StatusViewData.Concrete) statuses.getPairedItem(position))) ((StatusViewData.Concrete) statuses.getPairedItem(position)))
.setIsShowingSensitiveContent(isShowing).createStatusViewData(); .setIsShowingSensitiveContent(isShowing).createStatusViewData();
statuses.setPairedItem(position, newViewData); statuses.setPairedItem(position, newViewData);
adapter.changeItem(position, newViewData, false); updateAdapter();
} }
@Override @Override
@ -434,9 +555,10 @@ public class TimelineFragment extends SFragment implements
} }
sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), FetchEnd.MIDDLE, position); 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); statuses.setPairedItem(position, newViewData);
adapter.changeItem(position, newViewData, false); updateAdapter();
} else { } else {
Log.e(TAG, "error loading more"); Log.e(TAG, "error loading more");
} }
@ -530,10 +652,9 @@ public class TimelineFragment extends SFragment implements
@Override @Override
public void removeItem(int position) { public void removeItem(int position) {
statuses.remove(position); statuses.remove(position);
adapter.update(statuses.getPairedCopy()); updateAdapter();
} }
@Override
public void removeAllByAccountId(String accountId) { public void removeAllByAccountId(String accountId) {
// using iterator to safely remove items while iterating // using iterator to safely remove items while iterating
Iterator<Either<Placeholder, Status>> iterator = statuses.iterator(); Iterator<Either<Placeholder, Status>> iterator = statuses.iterator();
@ -543,15 +664,34 @@ public class TimelineFragment extends SFragment implements
iterator.remove(); iterator.remove();
} }
} }
adapter.update(statuses.getPairedCopy()); updateAdapter();
} }
private void onLoadMore() { 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); sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM, -1);
} }
private void fullyRefresh() { private void fullyRefresh() {
adapter.clear(); statuses.clear();
updateAdapter();
sendFetchTimelineRequest(null, null, FetchEnd.TOP, -1); sendFetchTimelineRequest(null, null, FetchEnd.TOP, -1);
} }
@ -560,7 +700,8 @@ public class TimelineFragment extends SFragment implements
} }
private boolean actionButtonPresent() { private boolean actionButtonPresent() {
return kind != Kind.TAG && kind != Kind.FAVOURITES; return kind != Kind.TAG && kind != Kind.FAVOURITES &&
getActivity() instanceof ActionButtonActivity;
} }
private void jumpToTop() { private void jumpToTop() {
@ -599,17 +740,6 @@ public class TimelineFragment extends SFragment implements
topFetches++; topFetches++;
return; 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>>() { Callback<List<Status>> callback = new Callback<List<Status>>() {
@Override @Override
@ -635,6 +765,7 @@ public class TimelineFragment extends SFragment implements
private void onFetchTimelineSuccess(List<Status> statuses, String linkHeader, private void onFetchTimelineSuccess(List<Status> statuses, String linkHeader,
FetchEnd fetchEnd, int pos) { FetchEnd fetchEnd, int pos) {
// We filled the hole (or reached the end) if the server returned less statuses than we // We filled the hole (or reached the end) if the server returned less statuses than we
// we asked for. // we asked for.
boolean fullFetch = statuses.size() >= LOAD_AT_ONCE; boolean fullFetch = statuses.size() >= LOAD_AT_ONCE;
@ -660,7 +791,13 @@ public class TimelineFragment extends SFragment implements
if (next != null) { if (next != null) {
fromId = next.uri.getQueryParameter("max_id"); 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); addItems(statuses, fromId);
} else { } else {
/* If this is the first fetch, also save the id from the "previous" link and /* 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); 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; break;
} }
} }
fulfillAnyQueuedFetches(fetchEnd); fulfillAnyQueuedFetches(fetchEnd);
if (statuses.size() == 0 && adapter.getItemCount() == 1) { progressBar.setVisibility(View.GONE);
adapter.setFooterState(FooterViewHolder.State.EMPTY);
} else {
adapter.setFooterState(FooterViewHolder.State.END);
}
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
if (this.statuses.size() == 0) {
nothingMessageView.setVisibility(View.VISIBLE);
}
} }
private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) { private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) {
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
if (fetchEnd == FetchEnd.MIDDLE && !statuses.get(position).isRight()) { 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); statuses.setPairedItem(position, newViewData);
adapter.changeItem(position, newViewData, true); updateAdapter();
} }
Log.e(TAG, "Fetch Failure: " + exception.getMessage()); Log.e(TAG, "Fetch Failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd); fulfillAnyQueuedFetches(fetchEnd);
progressBar.setVisibility(View.GONE);
} }
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) { private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) { switch (fetchEnd) {
case BOTTOM: { case BOTTOM: {
bottomLoading = false; bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
onLoadMore();
}
break; break;
} }
case TOP: { case TOP: {
@ -744,7 +887,7 @@ public class TimelineFragment extends SFragment implements
topId = toId; topId = toId;
} }
List<Either<Placeholder, Status>> liftedNew = listStatusList(newStatuses); List<Either<Placeholder, Status>> liftedNew = liftStatusList(newStatuses);
if (statuses.isEmpty()) { if (statuses.isEmpty()) {
statuses.addAll(liftedNew); statuses.addAll(liftedNew);
@ -758,39 +901,35 @@ public class TimelineFragment extends SFragment implements
int newIndex = liftedNew.indexOf(statuses.get(0)); int newIndex = liftedNew.indexOf(statuses.get(0));
if (newIndex == -1) { if (newIndex == -1) {
if (index == -1 && fullFetch) { if (index == -1 && fullFetch) {
liftedNew.add(Either.left(Placeholder.getInstance())); liftedNew.add(Either.left(newPlaceholder()));
} }
statuses.addAll(0, liftedNew); statuses.addAll(0, liftedNew);
} else { } else {
statuses.addAll(0, liftedNew.subList(0, newIndex)); statuses.addAll(0, liftedNew.subList(0, newIndex));
} }
} }
adapter.update(statuses.getPairedCopy()); updateAdapter();
} }
private void addItems(List<Status> newStatuses, @Nullable String fromId) { private void addItems(List<Status> newStatuses, @Nullable String fromId) {
if (ListUtils.isEmpty(newStatuses)) { if (ListUtils.isEmpty(newStatuses)) {
return; return;
} }
int end = statuses.size(); Status last = null;
Status last = statuses.get(end - 1).getAsRightOrNull(); 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 // 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 // types by ID anyway and we should change equals() for Status, I think, so this makes sense
if (last != null && !findStatus(newStatuses, last.getId())) { if (last != null && !findStatus(newStatuses, last.getId())) {
statuses.addAll(listStatusList(newStatuses)); statuses.addAll(liftStatusList(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);
}
if (fromId != null) { if (fromId != null) {
bottomId = fromId; bottomId = fromId;
} }
adapter.addItems(newViewDatas); updateAdapter();
} }
} }
@ -801,18 +940,18 @@ public class TimelineFragment extends SFragment implements
} }
if (ListUtils.isEmpty(newStatuses)) { if (ListUtils.isEmpty(newStatuses)) {
adapter.update(statuses.getPairedCopy()); updateAdapter();
return; return;
} }
List<Either<Placeholder, Status>> liftedNew = listStatusList(newStatuses); List<Either<Placeholder, Status>> liftedNew = liftStatusList(newStatuses);
if (fullFetch) { if (fullFetch) {
liftedNew.add(Either.left(Placeholder.getInstance())); liftedNew.add(Either.left(newPlaceholder()));
} }
statuses.addAll(pos, liftedNew); statuses.addAll(pos, liftedNew);
adapter.update(statuses.getPairedCopy()); updateAdapter();
} }
@ -825,6 +964,19 @@ public class TimelineFragment extends SFragment implements
return false; 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 = private final Function<Status, Either<Placeholder, Status>> statusLifter =
Either::right; Either::right;
@ -851,7 +1003,111 @@ public class TimelineFragment extends SFragment implements
return new Pair<>(statusToUpdate, positionToUpdate); 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); 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; package com.keylesspalace.tusky.fragment;
import android.arch.core.util.Function; import android.arch.core.util.Function;
import android.arch.lifecycle.Lifecycle;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
@ -24,11 +25,12 @@ import android.preference.PreferenceManager;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.design.widget.Snackbar; 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.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SimpleItemAnimator;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -39,14 +41,19 @@ import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewThreadActivity; import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.adapter.ThreadAdapter; 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.di.Injectable;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext; import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.util.ViewDataUtils;
@ -59,22 +66,29 @@ import java.util.Locale;
import javax.inject.Inject; import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
import static com.uber.autodispose.AutoDispose.*;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.*;
public final class ViewThreadFragment extends SFragment implements public final class ViewThreadFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable { SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable {
private static final String TAG = "ViewThreadFragment"; private static final String TAG = "ViewThreadFragment";
@Inject @Inject
public TimelineCases timelineCases; public TimelineCases timelineCases;
@Inject
public MastodonApi mastodonApi;
@Inject
public EventHub eventHub;
private SwipeRefreshLayout swipeRefreshLayout; private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView; private RecyclerView recyclerView;
private ThreadAdapter adapter; private ThreadAdapter adapter;
private String thisThreadsStatusId; private String thisThreadsStatusId;
private TimelineReceiver timelineReceiver;
private Card card; private Card card;
private boolean alwaysShowSensitiveMedia; private boolean alwaysShowSensitiveMedia;
@ -101,6 +115,36 @@ public final class ViewThreadFragment extends SFragment implements
return timelineCases; 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 @Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) { @Nullable Bundle savedInstanceState) {
@ -128,7 +172,6 @@ public final class ViewThreadFragment extends SFragment implements
R.drawable.conversation_thread_line_dark); R.drawable.conversation_thread_line_dark);
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context, recyclerView.addItemDecoration(new ConversationLineItemDecoration(context,
threadLineDrawable)); threadLineDrawable));
adapter = new ThreadAdapter(this);
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences( SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(
getActivity()); getActivity());
alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false); alwaysShowSensitiveMedia = preferences.getBoolean("alwaysShowSensitiveMedia", false);
@ -139,19 +182,11 @@ public final class ViewThreadFragment extends SFragment implements
statuses.clear(); statuses.clear();
thisThreadsStatusId = null; thisThreadsStatusId = null;
timelineReceiver = new TimelineReceiver(this, this); ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
LocalBroadcastManager.getInstance(context.getApplicationContext())
.registerReceiver(timelineReceiver, TimelineReceiver.getFilter(null));
return rootView; return rootView;
} }
@Override
public void onDestroyView() {
LocalBroadcastManager.getInstance(getContext())
.unregisterReceiver(timelineReceiver);
super.onDestroyView();
}
@Override @Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) { public void onActivityCreated(@Nullable Bundle savedInstanceState) {
@ -202,21 +237,8 @@ public final class ViewThreadFragment extends SFragment implements
@Override @Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.setReblogged(reblog); setReblogForStatus(position, status, reblog);
eventHub.dispatch(new ReblogEvent(status.getId(), reblog));
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setReblogged(reblog);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, false);
} }
} }
@ -228,6 +250,24 @@ public final class ViewThreadFragment extends SFragment implements
}); });
} }
private void setReblogForStatus(int position, Status status, boolean reblog) {
status.setReblogged(reblog);
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setReblogged(reblog);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@Override @Override
public void onFavourite(final boolean favourite, final int position) { public void onFavourite(final boolean favourite, final int position) {
final Status status = statuses.get(position); final Status status = statuses.get(position);
@ -235,21 +275,8 @@ public final class ViewThreadFragment extends SFragment implements
@Override @Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) { public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
status.setFavourited(favourite); setFavForStatus(position, status, favourite);
eventHub.dispatch(new FavoriteEvent(status.getId(), favourite));
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setFavourited(favourite);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, false);
} }
} }
@ -261,6 +288,24 @@ public final class ViewThreadFragment extends SFragment implements
}); });
} }
private void setFavForStatus(int position, Status status, boolean favourite) {
status.setFavourited(favourite);
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setFavourited(favourite);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@Override @Override
public void onMore(View view, int position) { public void onMore(View view, int position) {
super.more(statuses.get(position), view, position); super.more(statuses.get(position), view, position);
@ -334,8 +379,7 @@ public final class ViewThreadFragment extends SFragment implements
adapter.setStatuses(statuses.getPairedCopy()); adapter.setStatuses(statuses.getPairedCopy());
} }
@Override private void removeAllByAccountId(String accountId) {
public void removeAllByAccountId(String accountId) {
Status status = null; Status status = null;
if (!statuses.isEmpty()) { if (!statuses.isEmpty()) {
status = statuses.get(statusIndex); 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() { private void updateRevealIcon() {
ViewThreadActivity activity = ((ViewThreadActivity) getActivity()); ViewThreadActivity activity = ((ViewThreadActivity) getActivity());
if (activity == null) return; if (activity == null) return;
@ -545,8 +652,8 @@ public final class ViewThreadFragment extends SFragment implements
} }
} }
if (!hasAnyWarnings) { if (!hasAnyWarnings) {
activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN); activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN);
return; return;
} }
activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE : activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE :
ViewThreadActivity.REVEAL_BUTTON_REVEAL); ViewThreadActivity.REVEAL_BUTTON_REVEAL);

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 package com.keylesspalace.tusky.network
import android.content.Intent import com.keylesspalace.tusky.appstore.EventHub
import android.support.v4.content.LocalBroadcastManager 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.Relationship
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.receiver.TimelineReceiver
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -39,7 +40,7 @@ interface TimelineCases {
class TimelineCasesImpl( class TimelineCasesImpl(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val broadcastManager: LocalBroadcastManager private val eventHub: EventHub
) : TimelineCases { ) : TimelineCases {
override fun reblogWithCallback(status: Status, reblog: Boolean, callback: Callback<Status>) { override fun reblogWithCallback(status: Status, reblog: Boolean, callback: Callback<Status>) {
val id = status.actionableId val id = status.actionableId
@ -70,9 +71,7 @@ class TimelineCasesImpl(
override fun onFailure(call: Call<Relationship>, t: Throwable) {} override fun onFailure(call: Call<Relationship>, t: Throwable) {}
}) })
val intent = Intent(TimelineReceiver.Types.MUTE_ACCOUNT) eventHub.dispatch(MuteEvent(id))
intent.putExtra("id", id)
broadcastManager.sendBroadcast(intent)
} }
override fun block(id: String) { override fun block(id: String) {
@ -82,9 +81,8 @@ class TimelineCasesImpl(
override fun onFailure(call: Call<Relationship>, t: Throwable) {} override fun onFailure(call: Call<Relationship>, t: Throwable) {}
}) })
val intent = Intent(TimelineReceiver.Types.BLOCK_ACCOUNT) eventHub.dispatch(BlockEvent(id))
intent.putExtra("id", id)
broadcastManager.sendBroadcast(intent)
} }
override fun delete(id: String) { override fun delete(id: String) {
@ -94,6 +92,7 @@ class TimelineCasesImpl(
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {} 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.NotificationCompat
import android.support.v4.app.ServiceCompat import android.support.v4.app.ServiceCompat
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
import android.support.v4.content.LocalBroadcastManager
import com.keylesspalace.tusky.R 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.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.receiver.TimelineReceiver
import com.keylesspalace.tusky.util.SaveTootHelper import com.keylesspalace.tusky.util.SaveTootHelper
import com.keylesspalace.tusky.util.StringUtils import com.keylesspalace.tusky.util.StringUtils
import dagger.android.AndroidInjection import dagger.android.AndroidInjection
@ -30,14 +30,19 @@ import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
class SendTootService: Service(), Injectable { class SendTootService : Service(), Injectable {
@Inject @Inject
lateinit var mastodonApi: MastodonApi lateinit var mastodonApi: MastodonApi
@Inject @Inject
lateinit var accountManager: AccountManager lateinit var accountManager: AccountManager
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var database: AppDatabase
private lateinit var saveTootHelper: SaveTootHelper private lateinit var saveTootHelper: SaveTootHelper
@ -50,7 +55,7 @@ class SendTootService: Service(), Injectable {
override fun onCreate() { override fun onCreate() {
AndroidInjection.inject(this) AndroidInjection.inject(this)
saveTootHelper = SaveTootHelper(TuskyApplication.getDB().tootDao(), this) saveTootHelper = SaveTootHelper(database.tootDao(), this)
super.onCreate() super.onCreate()
} }
@ -60,13 +65,9 @@ class SendTootService: Service(), Injectable {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { 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) val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT)
?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
if (tootToSend == null) {
throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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) 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)) .setColor(ContextCompat.getColor(this, R.color.primary))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) .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) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build()) startForeground(sendingNotificationId, builder.build())
} else { } else {
@ -100,7 +101,7 @@ class SendTootService: Service(), Injectable {
} else { } else {
if(intent.hasExtra(KEY_CANCEL)) { if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
} }
@ -118,7 +119,7 @@ class SendTootService: Service(), Injectable {
// when account == null, user has logged out, cancel sending // when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(tootToSend.accountId) val account = accountManager.getAccountById(tootToSend.accountId)
if(account == null) { if (account == null) {
tootsToSend.remove(tootId) tootsToSend.remove(tootId)
notificationManager.cancel(tootId) notificationManager.cancel(tootId)
stopSelfWhenDone() stopSelfWhenDone()
@ -142,21 +143,19 @@ class SendTootService: Service(), Injectable {
sendCalls[tootId] = sendCall sendCalls[tootId] = sendCall
val callback = object: Callback<Status> { val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) { override fun onResponse(call: Call<Status>, response: Response<Status>) {
tootsToSend.remove(tootId) tootsToSend.remove(tootId)
if (response.isSuccessful) { 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 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) saveTootHelper.deleteDraft(tootToSend.savedTootUid)
} }
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
notificationManager.cancel(tootId) notificationManager.cancel(tootId)
} else { } else {
@ -179,7 +178,7 @@ class SendTootService: Service(), Injectable {
} }
override fun onFailure(call: Call<Status>, t: Throwable) { 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) { if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL backoff = MAX_RETRY_INTERVAL
} }
@ -206,7 +205,7 @@ class SendTootService: Service(), Injectable {
private fun cancelSending(tootId: Int) { private fun cancelSending(tootId: Int) {
val tootToCancel = tootsToSend.remove(tootId) val tootToCancel = tootsToSend.remove(tootId)
if(tootToCancel != null) { if (tootToCancel != null) {
val sendCall = sendCalls.remove(tootId) val sendCall = sendCalls.remove(tootId)
sendCall?.cancel() sendCall?.cancel()
@ -259,7 +258,7 @@ class SendTootService: Service(), Injectable {
private const val KEY_CANCEL = "cancel_id" private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_toots" 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 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 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 accountId: Long,
val savedTootUid: Int, val savedTootUid: Int,
val idempotencyKey: String, 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 { public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListener {
private static final int VISIBLE_THRESHOLD = 15; private static final int VISIBLE_THRESHOLD = 15;
private int currentPage;
private int previousTotalItemCount; private int previousTotalItemCount;
private boolean loading;
private int startingPageIndex;
private LinearLayoutManager layoutManager; private LinearLayoutManager layoutManager;
public EndlessOnScrollListener(LinearLayoutManager layoutManager) { public EndlessOnScrollListener(LinearLayoutManager layoutManager) {
this.layoutManager = layoutManager; this.layoutManager = layoutManager;
currentPage = 0;
previousTotalItemCount = 0; previousTotalItemCount = 0;
loading = true;
startingPageIndex = 0;
} }
@Override @Override
@ -39,28 +33,21 @@ public abstract class EndlessOnScrollListener extends RecyclerView.OnScrollListe
int totalItemCount = layoutManager.getItemCount(); int totalItemCount = layoutManager.getItemCount();
int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();
if (totalItemCount < previousTotalItemCount) { if (totalItemCount < previousTotalItemCount) {
currentPage = startingPageIndex;
previousTotalItemCount = totalItemCount; previousTotalItemCount = totalItemCount;
if (totalItemCount == 0) {
loading = true;
}
} }
if (loading && totalItemCount > previousTotalItemCount) { if (totalItemCount != previousTotalItemCount) {
loading = false;
previousTotalItemCount = totalItemCount; previousTotalItemCount = totalItemCount;
} }
if (!loading && lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) {
currentPage++; if (lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) {
onLoadMore(currentPage, totalItemCount, view); onLoadMore(totalItemCount, view);
loading = true;
} }
} }
public void reset() { public void reset() {
currentPage = startingPageIndex;
previousTotalItemCount = 0; 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 com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects;
/** /**
* Created by charlag on 11/07/2017. * Created by charlag on 11/07/2017.
@ -40,6 +42,10 @@ public abstract class StatusViewData {
private StatusViewData() { private StatusViewData() {
} }
public abstract long getViewDataId();
public abstract boolean deepEquals(StatusViewData other);
public static final class Concrete extends StatusViewData { public static final class Concrete extends StatusViewData {
private final String id; private final String id;
private final Spanned content; private final Spanned content;
@ -214,18 +220,84 @@ public abstract class StatusViewData {
return card; 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 { public static final class Placeholder extends StatusViewData {
private final boolean isLoading; private final boolean isLoading;
private final long id;
public Placeholder(boolean isLoading) { public Placeholder(long id, boolean isLoading) {
this.id = id;
this.isLoading = isLoading; this.isLoading = isLoading;
} }
public boolean isLoading() { public boolean isLoading() {
return 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 { public static class Builder {

View File

@ -1,12 +1,40 @@
<?xml version="1.0" encoding="utf-8"?> <?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"
android:id="@+id/swipe_refresh_layout" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:layout_gravity="top">
<android.support.v7.widget.RecyclerView <android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/recycler_view" android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent">
</android.support.v4.widget.SwipeRefreshLayout>
<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>
<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,9 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?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"
android:id="@+id/button_load_more" xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="72dp" android:layout_height="wrap_content">
android:text="@string/load_more_placeholder_text"
android:textColor="?attr/colorAccent" <Button
android:textSize="?attr/status_text_medium" /> android:id="@+id/button_load_more"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="match_parent"
android:layout_height="72dp"
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 android.widget.EditText
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager 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.Account
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
@ -33,6 +35,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.Mockito import org.mockito.Mockito
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.robolectric.Robolectric import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -50,8 +53,6 @@ import retrofit2.Response
class ComposeActivityTest { class ComposeActivityTest {
lateinit var activity: ComposeActivity lateinit var activity: ComposeActivity
lateinit var application: FakeTuskyApplication
lateinit var serviceLocator: TuskyApplication.ServiceLocator
lateinit var accountManagerMock: AccountManager lateinit var accountManagerMock: AccountManager
lateinit var apiMock: MastodonApi lateinit var apiMock: MastodonApi
@ -81,9 +82,6 @@ class ComposeActivityTest {
activity = controller.get() activity = controller.get()
accountManagerMock = Mockito.mock(AccountManager::class.java) 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) apiMock = Mockito.mock(MastodonApi::class.java)
`when`(apiMock.customEmojis).thenReturn(object: Call<List<Emoji>> { `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.mastodonApi = apiMock
activity.accountManager = accountManagerMock activity.accountManager = accountManagerMock
application = activity.application as FakeTuskyApplication activity.database = dbMock
application.locator = serviceLocator
`when`(accountManagerMock.activeAccount).thenReturn(account) `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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME