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