From 44a5b42cac856ed2cad4d998c17a68dca33fc9fb Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Fri, 11 Jun 2021 20:15:40 +0200 Subject: [PATCH] Timeline refactor (#2175) * Move Timeline files into their own package * Introduce TimelineViewModel, add coroutines * Simplify StatusViewData * Handle timeilne fetch errors * Rework filters, fix ViewThreadFragment * Fix NotificationsFragment * Simplify Notifications and Thread, handle pin * Redo loading in TimelineViewModel * Improve error handling in TimelineViewModel * Rewrite actions in TimelineViewModel * Apply feedback after timeline factoring review * Handle initial failure in timeline correctly --- app/build.gradle | 2 + .../keylesspalace/tusky/TimelineDAOTest.kt | 2 +- .../keylesspalace/tusky/FiltersActivity.kt | 42 +- .../com/keylesspalace/tusky/ListsActivity.kt | 4 +- .../com/keylesspalace/tusky/MainActivity.kt | 1 - .../tusky/ModalTimelineActivity.kt | 9 +- .../keylesspalace/tusky/StatusListActivity.kt | 4 +- .../java/com/keylesspalace/tusky/TabData.kt | 11 +- .../keylesspalace/tusky/ViewTagActivity.java | 2 +- .../tusky/adapter/NotificationsAdapter.java | 32 +- .../tusky/adapter/PlaceholderViewHolder.java | 2 +- .../tusky/adapter/StatusBaseViewHolder.java | 90 +- .../adapter/StatusDetailedViewHolder.java | 9 +- .../tusky/adapter/StatusViewHolder.java | 24 +- .../keylesspalace/tusky/appstore/Events.kt | 1 + .../conversation/ConversationEntity.kt | 6 +- .../conversation/ConversationsFragment.kt | 6 +- .../conversation/ConversationsViewModel.kt | 118 +- .../notifications/NotificationHelper.java | 3 +- .../report/adapter/StatusViewHolder.kt | 2 +- .../components/search/SearchViewModel.kt | 187 +-- .../search/adapter/SearchStatusesAdapter.kt | 2 +- .../fragments/SearchStatusesFragment.kt | 2 +- .../timeline}/TimelineAdapter.java | 4 +- .../components/timeline/TimelineFragment.kt | 563 ++++++++ .../components/timeline/TimelineRepository.kt | 413 ++++++ .../components/timeline/TimelineViewModel.kt | 903 ++++++++++++ .../com/keylesspalace/tusky/db/Converters.kt | 6 +- .../tusky/di/FragmentBuildersModule.kt | 1 + .../tusky/di/RepositoryModule.kt | 4 +- .../tusky/di/ViewModelFactory.kt | 7 + .../tusky/entity/Notification.kt | 26 +- .../com/keylesspalace/tusky/entity/Status.kt | 15 +- .../tusky/fragment/NotificationsFragment.java | 277 ++-- .../tusky/fragment/SFragment.java | 131 +- .../tusky/fragment/TimelineFragment.kt | 1265 ----------------- .../tusky/fragment/ViewThreadFragment.java | 252 ++-- .../interfaces/StatusActionListener.java | 2 +- .../tusky/network/FilterModel.kt | 56 + .../tusky/network/MastodonApi.kt | 2 +- .../tusky/network/TimelineCases.kt | 123 +- .../tusky/pager/AccountPagerAdapter.kt | 9 +- .../tusky/repository/TimelineRepository.kt | 392 ----- .../keylesspalace/tusky/util/LinkHelper.java | 9 +- .../util/ListStatusAccessibilityDelegate.kt | 227 +-- .../com/keylesspalace/tusky/util/ListUtils.kt | 4 + .../keylesspalace/tusky/util/StringUtils.kt | 9 + .../tusky/util/ViewDataUtils.java | 86 -- .../keylesspalace/tusky/util/ViewDataUtils.kt | 53 + .../view/ConversationLineItemDecoration.kt | 4 +- .../tusky/viewdata/AttachmentViewData.kt | 7 - .../tusky/viewdata/NotificationViewData.java | 8 +- .../tusky/viewdata/StatusViewData.java | 677 --------- .../tusky/viewdata/StatusViewData.kt | 144 ++ .../tusky/BottomSheetActivityTest.kt | 2 +- .../com/keylesspalace/tusky/FilterTest.kt | 298 ++-- .../timeline}/TimelineRepositoryTest.kt | 251 ++-- .../timeline/TimelineViewModelTest.kt | 783 ++++++++++ 58 files changed, 3956 insertions(+), 3618 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/{adapter => components/timeline}/TimelineAdapter.java (96%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt rename app/src/test/java/com/keylesspalace/tusky/{fragment => components/timeline}/TimelineRepositoryTest.kt (60%) create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index 91874f1c..ca917abd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -119,6 +119,8 @@ dependencies { implementation "androidx.work:work-runtime:2.5.0" implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0' kapt "androidx.room:room-compiler:$roomVersion" implementation "com.google.android.material:material:1.3.0" diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt index da55b08b..92288bba 100644 --- a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt +++ b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt @@ -5,7 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.keylesspalace.tusky.db.* import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.repository.TimelineRepository +import com.keylesspalace.tusky.components.timeline.TimelineRepository import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt index 7e91db07..1fe165b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt @@ -5,6 +5,7 @@ import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityFiltersBinding @@ -14,11 +15,14 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.io.IOException +import java.lang.Exception import javax.inject.Inject class FiltersActivity: BaseActivity() { @@ -162,37 +166,29 @@ class FiltersActivity: BaseActivity() { binding.addFilterButton.hide() binding.filterProgressBar.show() - api.getFilters().enqueue(object : Callback> { - override fun onResponse(call: Call>, response: Response>) { - val filterResponse = response.body() - if(response.isSuccessful && filterResponse != null) { - - filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList() - refreshFilterDisplay() - - binding.filtersView.show() - binding.addFilterButton.show() - binding.filterProgressBar.hide() - } else { - binding.filterProgressBar.hide() - binding.filterMessageView.show() - binding.filterMessageView.setup(R.drawable.elephant_error, - R.string.error_generic) { loadFilters() } - } - } - - override fun onFailure(call: Call>, t: Throwable) { + lifecycleScope.launch { + val newFilters = try { + api.getFilters().await() + } catch (t: Exception) { binding.filterProgressBar.hide() binding.filterMessageView.show() if (t is IOException) { binding.filterMessageView.setup(R.drawable.elephant_offline, - R.string.error_network) { loadFilters() } + R.string.error_network) { loadFilters() } } else { binding.filterMessageView.setup(R.drawable.elephant_error, - R.string.error_generic) { loadFilters() } + R.string.error_generic) { loadFilters() } } + return@launch } - }) + + filters = newFilters.filter { it.context.contains(context) }.toMutableList() + refreshFilterDisplay() + + binding.filtersView.show() + binding.addFilterButton.show() + binding.filterProgressBar.hide() + } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 5812a10f..cb6acd86 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -37,7 +37,7 @@ import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList -import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.viewmodel.ListsViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.* @@ -182,7 +182,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun onListSelected(listId: String) { startActivityWithSlideInAnimation( - ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId)) + ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId)) } private fun openListSettings(list: MastoList) { diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 972c15f3..96eb1e34 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -595,7 +595,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun changeAccount(newSelectedId: Long, forward: Intent?) { cacheUpdater.stop() - SFragment.flushFilters() accountManager.setActiveAccount(newSelectedId) val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK diff --git a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt index 64c22917..000cf3a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt @@ -5,7 +5,8 @@ import android.content.Intent import android.os.Bundle import com.google.android.material.floatingactionbutton.FloatingActionButton import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding -import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.interfaces.ActionButtonActivity import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -29,8 +30,8 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn } if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) { - val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind - ?: TimelineFragment.Kind.HOME + val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind + ?: TimelineViewModel.Kind.HOME val argument = intent?.getStringExtra(ARG_ARG) supportFragmentManager.beginTransaction() .replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument)) @@ -47,7 +48,7 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn private const val ARG_ARG = "arg" @JvmStatic - fun newIntent(context: Context, kind: TimelineFragment.Kind, + fun newIntent(context: Context, kind: TimelineViewModel.Kind, argument: String?): Intent { val intent = Intent(context, ModalTimelineActivity::class.java) intent.putExtra(ARG_KIND, kind) diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index b2691ee9..219d47df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -21,8 +21,8 @@ import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding -import com.keylesspalace.tusky.fragment.TimelineFragment -import com.keylesspalace.tusky.fragment.TimelineFragment.Kind +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind import javax.inject.Inject diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 4bf123b8..5eabec5f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -21,7 +21,8 @@ import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.fragment.NotificationsFragment -import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -47,7 +48,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD HOME, R.string.title_home, R.drawable.ic_home_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } + { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } ) NOTIFICATIONS -> TabData( NOTIFICATIONS, @@ -59,13 +60,13 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD LOCAL, R.string.title_public_local, R.drawable.ic_local_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) } + { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } ) FEDERATED -> TabData( FEDERATED, R.string.title_public_federated, R.drawable.ic_public_24dp, - { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } + { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } ) DIRECT -> TabData( DIRECT, @@ -85,7 +86,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD LIST, R.string.list, R.drawable.ic_list, - { args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty()) }, + { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, arguments, { arguments.getOrNull(1).orEmpty() } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java index 0ff6ff56..0071924b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewTagActivity.java @@ -25,7 +25,7 @@ import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; -import com.keylesspalace.tusky.fragment.TimelineFragment; +import com.keylesspalace.tusky.components.timeline.TimelineFragment; import java.util.Collections; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 0a872c6f..b42f4278 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -43,6 +43,7 @@ import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -195,14 +196,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } else { holder.showNotificationContent(true); - holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis()); - holder.setUsername(statusViewData.getNickname()); - holder.setCreatedAt(statusViewData.getCreatedAt()); + Status status = statusViewData.getActionable(); + holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); + holder.setUsername(status.getAccount().getUsername()); + holder.setCreatedAt(status.getCreatedAt()); - if(concreteNotificaton.getType() == Notification.Type.STATUS) { - holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot()); + if (concreteNotificaton.getType() == Notification.Type.STATUS) { + holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); } else { - holder.setAvatars(statusViewData.getAvatar(), + holder.setAvatars(status.getAccount().getAvatar(), concreteNotificaton.getAccount().getAvatar()); } } @@ -215,7 +217,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { if (payloadForHolder instanceof List) for (Object item : (List) payloadForHolder) { if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getCreatedAt()); + holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); } } } @@ -386,7 +388,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private StatusViewData.Concrete statusViewData; private SimpleDateFormat shortSdf; private SimpleDateFormat longSdf; - + private int avatarRadius48dp; private int avatarRadius36dp; private int avatarRadius24dp; @@ -415,7 +417,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { statusContent.setOnClickListener(this); shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); - + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -531,7 +533,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { message.setText(emojifiedText); if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); if (statusViewData.isExpanded()) { @@ -586,7 +588,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { notificationAvatar.setVisibility(View.VISIBLE); ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); + avatarRadius24dp, statusDisplayOptions.animateAvatars()); } @Override @@ -607,7 +609,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private void setupContentAndSpoiler(final LinkListener listener) { boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); if (!shouldShowContentIfSpoiler && hasSpoiler) { statusContent.setVisibility(View.GONE); } else { @@ -615,7 +617,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getStatusEmojis(); + List emojis = statusViewData.getActionable().getEmojis(); if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { contentCollapseButton.setOnClickListener(view -> { @@ -641,13 +643,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter { CharSequence emojifiedText = CustomEmojiHelper.emojify( content, emojis, statusContent, statusDisplayOptions.animateEmojis() ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener); CharSequence emojifiedContentWarning; if (statusViewData.getSpoilerText() != null) { emojifiedContentWarning = CustomEmojiHelper.emojify( statusViewData.getSpoilerText(), - statusViewData.getStatusEmojis(), + statusViewData.getActionable().getEmojis(), contentWarningDescriptionTextView, statusDisplayOptions.animateEmojis() ); 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 f8f1a0b5..9f85d981 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java @@ -28,7 +28,7 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder { private Button loadMoreButton; private ProgressBar progressBar; - PlaceholderViewHolder(View itemView) { + public PlaceholderViewHolder(View itemView) { super(itemView); loadMoreButton = itemView.findViewById(R.id.button_load_more); progressBar = itemView.findViewById(R.id.progressBar); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index d6cee626..36198d28 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -201,7 +201,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setSpoilerAndContent(boolean expanded, @NonNull Spanned content, @Nullable String spoilerText, - @Nullable Status.Mention[] mentions, + @Nullable List mentions, @NonNull List emojis, @Nullable PollViewData poll, @NonNull StatusDisplayOptions statusDisplayOptions, @@ -243,7 +243,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void setTextVisible(boolean sensitive, boolean expanded, Spanned content, - Status.Mention[] mentions, + List mentions, List emojis, @Nullable PollViewData poll, StatusDisplayOptions statusDisplayOptions, @@ -708,21 +708,23 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.setupWithStatus(status, listener, statusDisplayOptions, null); } - protected void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + public void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { if (payloads == null) { - setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions); - setUsername(status.getNickname()); - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); - setIsReply(status.getInReplyToId() != null); - setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); - setReblogged(status.isReblogged()); - setFavourited(status.isFavourited()); - setBookmarked(status.isBookmarked()); - List attachments = status.getAttachments(); - boolean sensitive = status.isSensitive(); + Status actionable = status.getActionable(); + setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setUsername(status.getUsername()); + setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); + setIsReply(actionable.getInReplyToId() != null); + setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), + actionable.getAccount().getBot(), statusDisplayOptions); + setReblogged(actionable.getReblogged()); + setFavourited(actionable.getFavourited()); + setBookmarked(actionable.getBookmarked()); + List attachments = actionable.getAttachments(); + boolean sensitive = actionable.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); @@ -747,11 +749,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions); } - setupButtons(listener, status.getSenderId(), status.getContent().toString(), + setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); - setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); + setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), statusDisplayOptions, listener); + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), + actionable.getMentions(), actionable.getEmojis(), + PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, + listener); setDescriptionForStatus(status, statusDisplayOptions); @@ -765,7 +770,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (payloads instanceof List) for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions); } } @@ -784,21 +789,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, StatusDisplayOptions statusDisplayOptions) { Context context = itemView.getContext(); + Status actionable = status.getActionable(); String description = context.getString(R.string.description_status, - status.getUserFullName(), + actionable.getAccount().getDisplayName(), getContentWarningDescription(context, status), - (TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""), - getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions), + (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), getReblogDescription(context, status), - status.getNickname(), - status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "", - status.isFavourited() ? context.getString(R.string.description_status_favourited) : "", - status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", + status.getUsername(), + actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "", + actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "", + actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", getMediaDescription(context, status), - getVisibilityDescription(context, status.getVisibility()), - getFavsText(context, status.getFavouritesCount()), - getReblogsText(context, status.getReblogsCount()), + getVisibilityDescription(context, actionable.getVisibility()), + getFavsText(context, actionable.getFavouritesCount()), + getReblogsText(context, actionable.getReblogsCount()), getPollDescription(status, context, statusDisplayOptions) ); itemView.setContentDescription(description); @@ -806,10 +812,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private static CharSequence getReblogDescription(Context context, @NonNull StatusViewData.Concrete status) { - String rebloggedUsername = status.getRebloggedByUsername(); - if (rebloggedUsername != null) { + Status reblog = status.getRebloggingStatus(); + if (reblog != null) { return context - .getString(R.string.status_boosted_format, rebloggedUsername); + .getString(R.string.status_boosted_format, reblog.getAccount().getUsername()); } else { return ""; } @@ -817,11 +823,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private static CharSequence getMediaDescription(Context context, @NonNull StatusViewData.Concrete status) { - if (status.getAttachments().isEmpty()) { + if (status.getActionable().getAttachments().isEmpty()) { return ""; } StringBuilder mediaDescriptions = CollectionsKt.fold( - status.getAttachments(), + status.getActionable().getAttachments(), new StringBuilder(), (builder, a) -> { if (a.getDescription() == null) { @@ -874,7 +880,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, Context context, StatusDisplayOptions statusDisplayOptions) { - PollViewData poll = status.getPoll(); + PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); if (poll == null) { return ""; } else { @@ -980,7 +986,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { StatusDisplayOptions statusDisplayOptions, Context context) { String votesText; - if(poll.getVotersCount() == null) { + if (poll.getVotersCount() == null) { String voters = numberFormat.format(poll.getVotesCount()); votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); } else { @@ -1004,12 +1010,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) { + final Card card = status.getActionable().getCard(); if (cardViewMode != CardViewMode.NONE && - status.getAttachments().size() == 0 && - status.getCard() != null && - !TextUtils.isEmpty(status.getCard().getUrl()) && + status.getActionable().getAttachments().size() == 0 && + card != null && + !TextUtils.isEmpty(card.getUrl()) && (!status.isCollapsible() || !status.isCollapsed())) { - final Card card = status.getCard(); cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { @@ -1028,7 +1034,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well - if (statusDisplayOptions.mediaPreviewEnabled() && !status.isSensitive() && !TextUtils.isEmpty(card.getImage())) { + if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) { int topLeftRadius = 0; int topRightRadius = 0; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index abb8ca85..ef2c704d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -101,7 +101,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected void setupWithStatus(final StatusViewData.Concrete status, + public void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { @@ -110,12 +110,13 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { if (payloads == null) { if (!statusDisplayOptions.hideStats()) { - setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); + setReblogAndFavCount(status.getActionable().getReblogsCount(), + status.getActionable().getFavouritesCount(), listener); } else { hideQuantitativeStats(); } - setApplication(status.getApplication()); + setApplication(status.getActionable().getApplication()); View.OnLongClickListener longClickListener = view -> { TextView textView = (TextView) view; @@ -130,7 +131,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { content.setOnLongClickListener(longClickListener); contentWarningDescription.setOnLongClickListener(longClickListener); - setStatusVisibility(status.getVisibility()); + setStatusVisibility(status.getActionable().getVisibility()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 68d64a69..68415989 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -26,6 +26,8 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; @@ -33,6 +35,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.List; + import at.connyduck.sparkbutton.helpers.Utils; public class StatusViewHolder extends StatusBaseViewHolder { @@ -54,19 +58,21 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - protected void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + public void setupWithStatus(StatusViewData.Concrete status, + final StatusActionListener listener, + StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { if (payloads == null) { setupCollapsedState(status, listener); - String rebloggedByDisplayName = status.getRebloggedByUsername(); - if (rebloggedByDisplayName == null) { + Status reblogging = status.getRebloggingStatus(); + if (reblogging == null) { hideStatusInfo(); } else { - setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions); + String rebloggedByDisplayName = reblogging.getAccount().getDisplayName(); + setRebloggedByDisplayName(rebloggedByDisplayName, + reblogging.getAccount().getEmojis(), statusDisplayOptions); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); } @@ -76,13 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder { } private void setRebloggedByDisplayName(final CharSequence name, - final StatusViewData.Concrete status, + final List accountEmoji, final StatusDisplayOptions statusDisplayOptions) { Context context = statusInfo.getContext(); CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); CharSequence emojifiedText = CustomEmojiHelper.emojify( - boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis() + boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() ); statusInfo.setText(emojifiedText); statusInfo.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 288de430..13baf07f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -21,3 +21,4 @@ data class MainTabsChangedEvent(val newTabs: List) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable data class DomainMuteEvent(val instance: String): Dispatchable data class AnnouncementReadEvent(val announcementId: String): Dispatchable +data class PinEvent(val statusId: String, val pinned: Boolean): Dispatchable diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index e35d460d..0ecfe3b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -73,7 +73,7 @@ data class ConversationStatusEntity( val sensitive: Boolean, val spoilerText: String, val attachments: ArrayList, - val mentions: Array, + val mentions: List, val showingHiddenContent: Boolean, val expanded: Boolean, val collapsible: Boolean, @@ -101,7 +101,7 @@ data class ConversationStatusEntity( if (sensitive != other.sensitive) return false if (spoilerText != other.spoilerText) return false if (attachments != other.attachments) return false - if (!mentions.contentEquals(other.mentions)) return false + if (mentions != other.mentions) return false if (showingHiddenContent != other.showingHiddenContent) return false if (expanded != other.expanded) return false if (collapsible != other.collapsible) return false @@ -125,7 +125,7 @@ data class ConversationStatusEntity( result = 31 * result + sensitive.hashCode() result = 31 * result + spoilerText.hashCode() result = 31 * result + attachments.hashCode() - result = 31 * result + mentions.contentHashCode() + result = 31 * result + mentions.hashCode() result = 31 * result + showingHiddenContent.hashCode() result = 31 * result + expanded.hashCode() result = 31 * result + collapsible.hashCode() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 009c62f6..43f250c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -40,6 +40,7 @@ import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { @@ -132,13 +133,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - viewMedia(attachmentIndex, it.toStatus(), view) + viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view) } } override fun onViewThread(position: Int) { viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { - viewThread(it.toStatus()) + val status = it.toStatus() + viewThread(status.actionableId, status.actionableStatus.url) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 04802427..5f2b9cdb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -15,17 +15,20 @@ import io.reactivex.rxjava3.schedulers.Schedulers import javax.inject.Inject class ConversationsViewModel @Inject constructor( - private val repository: ConversationsRepository, - private val timelineCases: TimelineCases, - private val database: AppDatabase, - private val accountManager: AccountManager + private val repository: ConversationsRepository, + private val timelineCases: TimelineCases, + private val database: AppDatabase, + private val accountManager: AccountManager ) : RxAwareViewModel() { private val repoResult = MutableLiveData>() - val conversations: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } - val networkState: LiveData = Transformations.switchMap(repoResult) { it.networkState } - val refreshState: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + val conversations: LiveData> = + Transformations.switchMap(repoResult) { it.pagedList } + val networkState: LiveData = + Transformations.switchMap(repoResult) { it.networkState } + val refreshState: LiveData = + Transformations.switchMap(repoResult) { it.refreshState } fun load() { val accountId = accountManager.activeAccount?.id ?: return @@ -45,57 +48,76 @@ class ConversationsViewModel @Inject constructor( fun favourite(favourite: Boolean, position: Int) { conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.favourite(conversation.lastStatus.toStatus(), favourite) - .flatMap { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(favourited = favourite) - ) + timelineCases.favourite(conversation.lastStatus.id, favourite) + .flatMap { + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(favourited = favourite) + ) - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } + .subscribeOn(Schedulers.io()) + .doOnError { t -> + Log.w( + "ConversationViewModel", + "Failed to favourite conversation", + t + ) + } + .onErrorReturnItem(0) + .subscribe() + .autoDispose() } } fun bookmark(bookmark: Boolean, position: Int) { conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark) - .flatMap { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) - ) + timelineCases.bookmark(conversation.lastStatus.id, bookmark) + .flatMap { + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) + ) - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } + .subscribeOn(Schedulers.io()) + .doOnError { t -> + Log.w( + "ConversationViewModel", + "Failed to bookmark conversation", + t + ) + } + .onErrorReturnItem(0) + .subscribe() + .autoDispose() } } fun voteInPoll(position: Int, choices: MutableList) { conversations.value?.getOrNull(position)?.let { conversation -> - timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices) - .flatMap { poll -> - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(poll = poll) - ) + val poll = conversation.lastStatus.poll ?: return + timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices) + .flatMap { newPoll -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(poll = newPoll) + ) - database.conversationDao().insert(newConversation) - } - .subscribeOn(Schedulers.io()) - .doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) } - .onErrorReturnItem(0) - .subscribe() - .autoDispose() + database.conversationDao().insert(newConversation) + } + .subscribeOn(Schedulers.io()) + .doOnError { t -> + Log.w( + "ConversationViewModel", + "Failed to favourite conversation", + t + ) + } + .onErrorReturnItem(0) + .subscribe() + .autoDispose() } } @@ -103,7 +125,7 @@ class ConversationsViewModel @Inject constructor( fun expandHiddenStatus(expanded: Boolean, position: Int) { conversations.value?.getOrNull(position)?.let { conversation -> val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(expanded = expanded) + lastStatus = conversation.lastStatus.copy(expanded = expanded) ) saveConversationToDb(newConversation) } @@ -112,7 +134,7 @@ class ConversationsViewModel @Inject constructor( fun collapseLongStatus(collapsed: Boolean, position: Int) { conversations.value?.getOrNull(position)?.let { conversation -> val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(collapsed = collapsed) + lastStatus = conversation.lastStatus.copy(collapsed = collapsed) ) saveConversationToDb(newConversation) } @@ -121,7 +143,7 @@ class ConversationsViewModel @Inject constructor( fun showContent(showing: Boolean, position: Int) { conversations.value?.getOrNull(position)?.let { conversation -> val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) + lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) ) saveConversationToDb(newConversation) } @@ -135,8 +157,8 @@ class ConversationsViewModel @Inject constructor( private fun saveConversationToDb(conversation: ConversationEntity) { database.conversationDao().insert(conversation) - .subscribeOn(Schedulers.io()) - .subscribe() + .subscribeOn(Schedulers.io()) + .subscribe() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index d6b9cb99..2218e0b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -316,7 +316,7 @@ public class NotificationHelper { Status actionableStatus = status.getActionableStatus(); Status.Visibility replyVisibility = actionableStatus.getVisibility(); String contentWarning = actionableStatus.getSpoilerText(); - Status.Mention[] mentions = actionableStatus.getMentions(); + List mentions = actionableStatus.getMentions(); List mentionedUsernames = new ArrayList<>(); mentionedUsernames.add(actionableStatus.getAccount().getUsername()); for (Status.Mention mention : mentions) { @@ -381,7 +381,6 @@ public class NotificationHelper { NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); - //noinspection ConstantConditions notificationManager.createNotificationChannelGroup(channelGroup); for (int i = 0; i < channelIds.length; i++) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 90579a92..5ac3dd6a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -118,7 +118,7 @@ class StatusViewHolder( private fun setTextVisible(expanded: Boolean, content: Spanned, - mentions: Array?, + mentions: List?, emojis: List, listener: LinkListener) { if (expanded) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 7052e8b7..2fdb7629 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -15,13 +15,12 @@ import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single - import javax.inject.Inject class SearchViewModel @Inject constructor( - mastodonApi: MastodonApi, - private val timelineCases: TimelineCases, - private val accountManager: AccountManager + mastodonApi: MastodonApi, + private val timelineCases: TimelineCases, + private val accountManager: AccountManager ) : RxAwareViewModel() { var currentQuery: String = "" @@ -36,93 +35,109 @@ class SearchViewModel @Inject constructor( val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false - private val statusesRepository = SearchRepository>(mastodonApi) + private val statusesRepository = + SearchRepository>(mastodonApi) private val accountsRepository = SearchRepository(mastodonApi) private val hashtagsRepository = SearchRepository(mastodonApi) private val repoResultStatus = MutableLiveData>>() - val statuses: LiveData>> = repoResultStatus.switchMap { it.pagedList } + val statuses: LiveData>> = + repoResultStatus.switchMap { it.pagedList } val networkStateStatus: LiveData = repoResultStatus.switchMap { it.networkState } - val networkStateStatusRefresh: LiveData = repoResultStatus.switchMap { it.refreshState } + val networkStateStatusRefresh: LiveData = + repoResultStatus.switchMap { it.refreshState } private val repoResultAccount = MutableLiveData>() val accounts: LiveData> = repoResultAccount.switchMap { it.pagedList } - val networkStateAccount: LiveData = repoResultAccount.switchMap { it.networkState } - val networkStateAccountRefresh: LiveData = repoResultAccount.switchMap { it.refreshState } + val networkStateAccount: LiveData = + repoResultAccount.switchMap { it.networkState } + val networkStateAccountRefresh: LiveData = + repoResultAccount.switchMap { it.refreshState } private val repoResultHashTag = MutableLiveData>() val hashtags: LiveData> = repoResultHashTag.switchMap { it.pagedList } - val networkStateHashTag: LiveData = repoResultHashTag.switchMap { it.networkState } - val networkStateHashTagRefresh: LiveData = repoResultHashTag.switchMap { it.refreshState } + val networkStateHashTag: LiveData = + repoResultHashTag.switchMap { it.networkState } + val networkStateHashTagRefresh: LiveData = + repoResultHashTag.switchMap { it.refreshState } private val loadedStatuses = ArrayList>() fun search(query: String) { loadedStatuses.clear() - repoResultStatus.value = statusesRepository.getSearchData(SearchType.Status, query, disposables, initialItems = loadedStatuses) { - it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) } - .orEmpty() - .apply { - loadedStatuses.addAll(this) - } - } - repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) { - it?.accounts.orEmpty() + repoResultStatus.value = statusesRepository.getSearchData( + SearchType.Status, + query, + disposables, + initialItems = loadedStatuses + ) { + it?.statuses?.map { status -> + Pair( + status, + status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler) + ) + } + .orEmpty() + .apply { + loadedStatuses.addAll(this) + } } + repoResultAccount.value = + accountsRepository.getSearchData(SearchType.Account, query, disposables) { + it?.accounts.orEmpty() + } val hashtagQuery = if (query.startsWith("#")) query else "#$query" repoResultHashTag.value = - hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) { - it?.hashtags.orEmpty() - } + hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) { + it?.hashtags.orEmpty() + } } fun removeItem(status: Pair) { timelineCases.delete(status.first.id) - .subscribe({ - if (loadedStatuses.remove(status)) - repoResultStatus.value?.refresh?.invoke() - }, { - err -> Log.d(TAG, "Failed to delete status", err) - }) - .autoDispose() + .subscribe({ + if (loadedStatuses.remove(status)) + repoResultStatus.value?.refresh?.invoke() + }, { err -> + Log.d(TAG, "Failed to delete status", err) + }) + .autoDispose() } fun expandedChange(status: Pair, expanded: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsExpanded(expanded).createStatusViewData()) + val newPair = Pair(status.first, status.second.copy(isExpanded = expanded)) loadedStatuses[idx] = newPair repoResultStatus.value?.refresh?.invoke() } } fun reblog(status: Pair, reblog: Boolean) { - timelineCases.reblog(status.first, reblog) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { setRebloggedForStatus(status, reblog) }, - { err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) } - ) - .autoDispose() + timelineCases.reblog(status.first.id, reblog) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { setRebloggedForStatus(status, reblog) }, + { err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) } + ) + .autoDispose() } - private fun setRebloggedForStatus(status: Pair, reblog: Boolean) { + private fun setRebloggedForStatus( + status: Pair, + reblog: Boolean + ) { status.first.reblogged = reblog status.first.reblog?.reblogged = reblog - val idx = loadedStatuses.indexOf(status) - if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() - } + repoResultStatus.value?.refresh?.invoke() } fun contentHiddenChange(status: Pair, isShowing: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setIsShowingSensitiveContent(isShowing).createStatusViewData()) + val newPair = Pair(status.first, status.second.copy(isShowingContent = isShowing)) loadedStatuses[idx] = newPair repoResultStatus.value?.refresh?.invoke() } @@ -131,7 +146,7 @@ class SearchViewModel @Inject constructor( fun collapsedChange(status: Pair, collapsed: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setCollapsed(collapsed).createStatusViewData()) + val newPair = Pair(status.first, status.second.copy(isCollapsed = collapsed)) loadedStatuses[idx] = newPair repoResultStatus.value?.refresh?.invoke() } @@ -140,54 +155,46 @@ class SearchViewModel @Inject constructor( fun voteInPoll(status: Pair, choices: MutableList) { val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices) updateStatus(status, votedPoll) - timelineCases.voteInPoll(status.first, choices) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { newPoll -> updateStatus(status, newPoll) }, - { t -> - Log.d(TAG, - "Failed to vote in poll: ${status.first.id}", t) - } - ) - .autoDispose() + timelineCases.voteInPoll(status.first.id, votedPoll.id, choices) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { newPoll -> updateStatus(status, newPoll) }, + { t -> + Log.d( + TAG, + "Failed to vote in poll: ${status.first.id}", t + ) + } + ) + .autoDispose() } private fun updateStatus(status: Pair, newPoll: Poll) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - - val newViewData = StatusViewData.Builder(status.second) - .setPoll(newPoll) - .createStatusViewData() - loadedStatuses[idx] = Pair(status.first, newViewData) + val newStatus = status.first.copy(poll = newPoll) + val newViewData = status.second.copy(status = newStatus) + loadedStatuses[idx] = Pair(newStatus, newViewData) repoResultStatus.value?.refresh?.invoke() } } fun favorite(status: Pair, isFavorited: Boolean) { - val idx = loadedStatuses.indexOf(status) - if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() - } - timelineCases.favourite(status.first, isFavorited) - .onErrorReturnItem(status.first) - .subscribe() - .autoDispose() + status.first.favourited = isFavorited + repoResultStatus.value?.refresh?.invoke() + timelineCases.favourite(status.first.id, isFavorited) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() } fun bookmark(status: Pair, isBookmarked: Boolean) { - val idx = loadedStatuses.indexOf(status) - if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setBookmarked(isBookmarked).createStatusViewData()) - loadedStatuses[idx] = newPair - repoResultStatus.value?.refresh?.invoke() - } - timelineCases.bookmark(status.first, isBookmarked) - .onErrorReturnItem(status.first) - .subscribe() - .autoDispose() + status.first.bookmarked = isBookmarked + repoResultStatus.value?.refresh?.invoke() + timelineCases.bookmark(status.first.id, isBookmarked) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() } fun getAllAccountsOrderedByActive(): List { @@ -199,7 +206,7 @@ class SearchViewModel @Inject constructor( } fun pinAccount(status: Status, isPin: Boolean) { - timelineCases.pin(status, isPin) + timelineCases.pin(status.id, isPin) } fun blockAccount(accountId: String) { @@ -217,14 +224,18 @@ class SearchViewModel @Inject constructor( fun muteConversation(status: Pair, mute: Boolean) { val idx = loadedStatuses.indexOf(status) if (idx >= 0) { - val newPair = Pair(status.first, StatusViewData.Builder(status.second).setMuted(mute).createStatusViewData()) + val newStatus = status.first.copy(muted = mute) + val newPair = Pair( + newStatus, + status.second.copy(status = newStatus) + ) loadedStatuses[idx] = newPair repoResultStatus.value?.refresh?.invoke() } - timelineCases.muteConversation(status.first, mute) - .onErrorReturnItem(status.first) - .subscribe() - .autoDispose() + timelineCases.muteConversation(status.first.id, mute) + .onErrorReturnItem(status.first) + .subscribe() + .autoDispose() } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index 0fcee37d..a40414f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -52,7 +52,7 @@ class SearchStatusesAdapter( val STATUS_COMPARATOR = object : DiffUtil.ItemCallback>() { override fun areContentsTheSame(oldItem: Pair, newItem: Pair): Boolean = - oldItem.second.deepEquals(newItem.second) + oldItem.second == newItem.second override fun areItemsTheSame(oldItem: Pair, newItem: Pair): Boolean = oldItem.second.id == newItem.second.id diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index a14fc84e..06013ce4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -383,7 +383,7 @@ class SearchStatusesFragment : SearchFragment): Boolean { + private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { return mentions.firstOrNull { account?.username == it.username && account.domain == Uri.parse(it.url)?.host } != null diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java similarity index 96% rename from app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java rename to app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java index 4be922d6..ec6954de 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter; +package com.keylesspalace.tusky.components.timeline; import android.view.LayoutInflater; import android.view.View; @@ -24,6 +24,8 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder; +import com.keylesspalace.tusky.adapter.StatusViewHolder; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt new file mode 100644 index 00000000..b0fc5d14 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -0,0 +1,563 @@ +/* Copyright 2021 Tusky Contributors + * + * 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.components.timeline + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityManager +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.* +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.* +import autodispose2.androidx.lifecycle.autoDispose +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.databinding.FragmentTimelineBinding +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable, + ReselectableFragment, RefreshableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var accountManager: AccountManager + + private val viewModel: TimelineViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTimelineBinding::bind) + + private lateinit var adapter: TimelineAdapter + + private var isSwipeToRefreshEnabled = true + + private var eventRegistered = false + + private var layoutManager: LinearLayoutManager? = null + private var scrollListener: EndlessOnScrollListener? = null + private var hideFab = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val arguments = requireArguments() + val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) + val id: String? = if (kind == TimelineViewModel.Kind.USER || + kind == TimelineViewModel.Kind.USER_PINNED || + kind == TimelineViewModel.Kind.USER_WITH_REPLIES || + kind == TimelineViewModel.Kind.LIST + ) { + arguments.getString(ID_ARG)!! + } else { + null + } + + val tags = if (kind == TimelineViewModel.Kind.TAG) { + arguments.getStringArrayList(HASHTAGS_ARG)!! + } else { + listOf() + } + viewModel.init( + kind, + id, + tags, + ) + + viewModel.viewUpdates + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this) + .subscribe { this.updateViews() } + + isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean( + PrefKeys.SHOW_CARDS_IN_TIMELINES, + false + ) + ) CardViewMode.INDENTED else CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) + adapter = TimelineAdapter( + dataSource, + statusDisplayOptions, + this + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupSwipeRefreshLayout() + setupRecyclerView() + updateViews() + viewModel.loadInitial() + } + + private fun setupSwipeRefreshLayout() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + } + + private fun setupRecyclerView() { + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate(binding.recyclerView, this) + { pos -> viewModel.statuses.getOrNull(pos) } + ) + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + + // CWs are expanded without animation, buttons animate itself, we don't need it basically + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + binding.recyclerView.adapter = adapter + } + + private fun showEmptyView() { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't + * guaranteed to be set until then. */ + scrollListener = if (actionButtonPresent()) { + /* Use a modified scroll listener that both loads more statuses as it goes, and hides + * the follow button on down-scroll. */ + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + hideFab = preferences.getBoolean("fabHide", false) + object : EndlessOnScrollListener(layoutManager) { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(view, dx, dy) + val composeButton = (activity as ActionButtonActivity).actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + this@TimelineFragment.onLoadMore() + } + } + } else { + // Just use the basic scroll listener to load more statuses. + object : EndlessOnScrollListener(layoutManager) { + override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { + this@TimelineFragment.onLoadMore() + } + } + }.also { + binding.recyclerView.addOnScrollListener(it) + } + + if (!eventRegistered) { + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event -> + when (event) { + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + eventRegistered = true + } + } + + override fun onRefresh() { + binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled + binding.statusView.hide() + + viewModel.refresh() + } + + override fun onReply(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + super.reply(status.status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + viewModel.reblog(reblog, position) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + viewModel.favorite(favourite, position) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + viewModel.bookmark(bookmark, position) + } + + override fun onVoteInPoll(position: Int, choices: List) { + viewModel.voteInPoll(position, choices) + } + + override fun onMore(view: View, position: Int) { + val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return + super.more(status, view, position) + } + + override fun onOpenReblog(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return + super.openReblog(status) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + viewModel.changeExpanded(expanded, position) + updateViews() + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + viewModel.changeContentHidden(isShowing, position) + updateViews() + } + + override fun onShowReblogs(position: Int) { + val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onShowFavs(position: Int) { + val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return + val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onLoadMore(position: Int) { + viewModel.loadGap(position) + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + viewModel.changeContentCollapsed(isCollapsed, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + super.viewMedia( + attachmentIndex, + AttachmentViewData.list(status.actionable), + view + ) + } + + override fun onViewThread(position: Int) { + val status = viewModel.statuses[position].asStatusOrNull() ?: return + super.viewThread(status.actionable.id, status.actionable.url) + } + + override fun onViewTag(tag: String) { + if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 && + viewModel.tags.contains(tag) + ) { + // If already viewing a tag page, then ignore any request to view that tag again. + return + } + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + if ((viewModel.kind == TimelineViewModel.Kind.USER || + viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES) && + viewModel.id == id + ) { + /* If already viewing an account page, then any requests to view that account page + * should be ignored. */ + return + } + super.viewAccount(id) + } + + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + updateViews() + } + } + } + } + + public override fun removeItem(position: Int) { + viewModel.statuses.removeAt(position) + updateViews() + } + + private fun onLoadMore() { + viewModel.loadMore() + } + + private fun actionButtonPresent(): Boolean { + return viewModel.kind != TimelineViewModel.Kind.TAG && + viewModel.kind != TimelineViewModel.Kind.FAVOURITES && + viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && + activity is ActionButtonActivity + } + + private fun updateViews() { + differ.submitList(viewModel.statuses.toList()) + binding.swipeRefreshLayout.isEnabled = viewModel.failure == null + + if (isAdded) { + binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing + binding.progressBar.visible(viewModel.isLoadingInitially) + if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) { + showEmptyView() + } else { + when (viewModel.failure) { + TimelineViewModel.FailureReason.NETWORK -> { + binding.statusView.show() + binding.statusView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { + binding.statusView.hide() + viewModel.loadInitial() + } + } + TimelineViewModel.FailureReason.OTHER -> { + binding.statusView.show() + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { + binding.statusView.hide() + viewModel.loadInitial() + } + } + null -> binding.statusView.hide() + } + } + } + } + + private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + if (isAdded) { + adapter.notifyItemRangeInserted(position, count) + val context = context + // scroll up when new items at the top are loaded while being in the first position + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.itemCount != count) { + if (isSwipeToRefreshEnabled) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) + } else binding.recyclerView.scrollToPosition(0) + } + } + } + + override fun onRemoved(position: Int, count: Int) { + adapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + adapter.notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + adapter.notifyItemRangeChanged(position, count, payload) + } + } + private val differ = AsyncListDiffer( + listUpdateCallback, + AsyncDifferConfig.Builder(diffCallback).build() + ) + + private val dataSource: TimelineAdapter.AdapterDataSource = + object : TimelineAdapter.AdapterDataSource { + override fun getItemCount(): Int { + return differ.currentList.size + } + + override fun getItemAt(pos: Int): StatusViewData { + return differ.currentList[pos] + } + } + + private var talkBackWasEnabled = false + + override fun onResume() { + super.onResume() + val a11yManager = + ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) + + val wasEnabled = talkBackWasEnabled + talkBackWasEnabled = a11yManager?.isEnabled == true + Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") + if (talkBackWasEnabled && !wasEnabled) { + adapter.notifyDataSetChanged() + } + startUpdateTimestamp() + } + + /** + * Start to update adapter every minute to refresh timestamp + * If setting absoluteTimeView is false + * Auto dispose observable on pause + */ + private fun startUpdateTimestamp() { + val preferences = PreferenceManager.getDefaultSharedPreferences(activity) + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + if (!useAbsoluteTime) { + Observable.interval(1, TimeUnit.MINUTES) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_PAUSE) + .subscribe { updateViews() } + } + } + + override fun onReselect() { + if (isAdded) { + layoutManager!!.scrollToPosition(0) + binding.recyclerView.stopScroll() + scrollListener!!.reset() + } + } + + override fun refreshContent() { + onRefresh() + } + + companion object { + private const val TAG = "TimelineF" // logging tag + private const val KIND_ARG = "kind" + private const val ID_ARG = "id" + private const val HASHTAGS_ARG = "hashtags" + private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" + + + fun newInstance( + kind: TimelineViewModel.Kind, + hashtagOrId: String? = null, + enableSwipeToRefresh: Boolean = true + ): TimelineFragment { + val fragment = TimelineFragment() + val arguments = Bundle(3) + arguments.putString(KIND_ARG, kind.name) + arguments.putString(ID_ARG, hashtagOrId) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) + fragment.arguments = arguments + return fragment + } + + @JvmStatic + fun newHashtagInstance(hashtags: List): TimelineFragment { + val fragment = TimelineFragment() + val arguments = Bundle(3) + arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name) + arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) + arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) + fragment.arguments = arguments + return fragment + } + + + private val diffCallback: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: StatusViewData, + newItem: StatusViewData + ): Boolean { + return oldItem.viewDataId == newItem.viewDataId + } + + override fun areContentsTheSame( + oldItem: StatusViewData, + newItem: StatusViewData + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: StatusViewData, + newItem: StatusViewData + ): Any? { + return if (oldItem === newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else // If items are different - update the whole view holder + null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt new file mode 100644 index 00000000..dac28559 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt @@ -0,0 +1,413 @@ +package com.keylesspalace.tusky.components.timeline + +import android.text.SpannedString +import androidx.core.text.parseAsHtml +import androidx.core.text.toHtml +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK +import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.collections.ArrayList + +data class Placeholder(val id: String) + +typealias TimelineStatus = Either + +enum class TimelineRequestMode { + DISK, NETWORK, ANY +} + +interface TimelineRepository { + fun getStatuses( + maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, + requestMode: TimelineRequestMode + ): Single> + + companion object { + val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) + } +} + +class TimelineRepositoryImpl( + private val timelineDao: TimelineDao, + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val gson: Gson +) : TimelineRepository { + + init { + this.cleanup() + } + + override fun getStatuses( + maxId: String?, sinceId: String?, sincedIdMinusOne: String?, + limit: Int, requestMode: TimelineRequestMode + ): Single> { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + return if (requestMode == DISK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) + } + } + + private fun getStatusesFromNetwork( + maxId: String?, sinceId: String?, + sinceIdMinusOne: String?, limit: Int, + accountId: Long, requestMode: TimelineRequestMode + ): Single> { + return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) + .map { response -> + this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) + } + .flatMap { statuses -> + this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) + } + .onErrorResumeNext { error -> + if (error is IOException && requestMode != NETWORK) { + this.getStatusesFromDb(accountId, maxId, sinceId, limit) + } else { + Single.error(error) + } + } + } + + private fun addFromDbIfNeeded( + accountId: Long, statuses: List>, + maxId: String?, sinceId: String?, limit: Int, + requestMode: TimelineRequestMode + ): Single> { + return if (requestMode != NETWORK && statuses.size < 2) { + val newMaxID = if (statuses.isEmpty()) { + maxId + } else { + statuses.last { it.isRight() }.asRight().id + } + this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) + .map { fromDb -> + // If it's just placeholders and less than limit (so we exhausted both + // db and server at this point) + if (fromDb.size < limit && fromDb.all { !it.isRight() }) { + statuses + } else { + statuses + fromDb + } + } + } else { + Single.just(statuses) + } + } + + private fun getStatusesFromDb( + accountId: Long, maxId: String?, sinceId: String?, + limit: Int + ): Single> { + return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) + .subscribeOn(Schedulers.io()) + .map { statuses -> + statuses.map { it.toStatus() } + } + } + + private fun saveStatusesToDb( + accountId: Long, statuses: List, + maxId: String?, sinceId: String? + ): List> { + var placeholderToInsert: Placeholder? = null + + // Look for overlap + val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { + val indexOfSince = statuses.indexOfLast { it.id == sinceId } + if (indexOfSince == -1) { + // We didn't find the status which must be there. Add a placeholder + placeholderToInsert = Placeholder(sinceId.inc()) + statuses.mapTo(mutableListOf(), Status::lift) + .apply { + add(Either.Left(placeholderToInsert)) + } + } else { + // There was an overlap. Remove all overlapped statuses. No need for a placeholder. + statuses.mapTo(mutableListOf(), Status::lift) + .apply { + subList(indexOfSince, size).clear() + } + } + } else { + // Just a normal case. + statuses.map(Status::lift) + } + + Single.fromCallable { + + if (statuses.isNotEmpty()) { + timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) + } + + for (status in statuses) { + timelineDao.insertInTransaction( + status.toEntity(accountId, gson), + status.account.toEntity(accountId, gson), + status.reblog?.account?.toEntity(accountId, gson) + ) + } + + placeholderToInsert?.let { + timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) + } + + // If we're loading in the bottom insert placeholder after every load + // (for requests on next launches) but not return it. + if (sinceId == null && statuses.isNotEmpty()) { + timelineDao.insertStatusIfNotThere( + Placeholder(statuses.last().id.dec()).toEntity(accountId) + ) + } + + // There may be placeholders which we thought could be from our TL but they are not + if (statuses.size > 2) { + timelineDao.removeAllPlaceholdersBetween( + accountId, statuses.first().id, + statuses.last().id + ) + } else if (placeholderToInsert == null && maxId != null && sinceId != null) { + timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) + } + } + .subscribeOn(Schedulers.io()) + .subscribe() + + return resultStatuses + } + + private fun cleanup() { + Schedulers.io().scheduleDirect { + val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL + timelineDao.cleanup(olderThan) + } + } + + private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { + if (this.status.authorServerId == null) { + return Either.Left(Placeholder(this.status.serverId)) + } + + val attachments: ArrayList = gson.fromJson( + status.attachments, + object : TypeToken>() {}.type + ) ?: ArrayList() + val mentions: List = gson.fromJson( + status.mentions, + object : TypeToken>() {}.type + ) ?: listOf() + val application = gson.fromJson(status.application, Status.Application::class.java) + val emojis: List = gson.fromJson( + status.emojis, + object : TypeToken>() {}.type + ) ?: listOf() + val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) + + val reblog = status.reblogServerId?.let { id -> + Status( + id = id, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() + ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + muted = status.muted, + poll = poll, + card = null + ) + } + val status = if (reblog != null) { + Status( + id = status.serverId, + url = null, // no url for reblogs + account = this.reblogAccount!!.toAccount(gson), + inReplyToId = null, + inReplyToAccountId = null, + reblog = reblog, + content = SpannedString(""), + createdAt = Date(status.createdAt), // lie but whatever? + emojis = listOf(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = status.visibility!!, + attachments = ArrayList(), + mentions = listOf(), + application = null, + pinned = false, + muted = status.muted, + poll = null, + card = null + ) + } else { + Status( + id = status.serverId, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() + ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText!!, + visibility = status.visibility!!, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + muted = status.muted, + poll = poll, + card = null + ) + } + return Either.Right(status) + } +} + +private val emojisListTypeToken = object : TypeToken>() {} + +fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { + return TimelineAccountEntity( + serverId = id, + timelineUserId = accountId, + localUsername = localUsername, + username = username, + displayName = name, + url = url, + avatar = avatar, + emojis = gson.toJson(emojis), + bot = bot + ) +} + +fun TimelineAccountEntity.toAccount(gson: Gson): Account { + return Account( + id = serverId, + localUsername = localUsername, + username = username, + displayName = displayName, + note = SpannedString(""), + url = url, + avatar = avatar, + header = "", + locked = false, + followingCount = 0, + followersCount = 0, + statusesCount = 0, + source = null, + bot = bot, + emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), + fields = null, + moved = null + ) +} + + +fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = this.id, + url = null, + timelineUserId = timelineUserId, + authorServerId = null, + inReplyToId = null, + inReplyToAccountId = null, + content = null, + createdAt = 0L, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = null, + visibility = null, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + muted = false + ) +} + +fun Status.toEntity( + timelineUserId: Long, + gson: Gson +): TimelineStatusEntity { + val actionable = actionableStatus + return TimelineStatusEntity( + serverId = this.id, + url = actionable.url!!, + timelineUserId = timelineUserId, + authorServerId = actionable.account.id, + inReplyToId = actionable.inReplyToId, + inReplyToAccountId = actionable.inReplyToAccountId, + content = actionable.content.toHtml(), + createdAt = actionable.createdAt.time, + emojis = actionable.emojis.let(gson::toJson), + reblogsCount = actionable.reblogsCount, + favouritesCount = actionable.favouritesCount, + reblogged = actionable.reblogged, + favourited = actionable.favourited, + bookmarked = actionable.bookmarked, + sensitive = actionable.sensitive, + spoilerText = actionable.spoilerText, + visibility = actionable.visibility, + attachments = actionable.attachments.let(gson::toJson), + mentions = actionable.mentions.let(gson::toJson), + application = actionable.application.let(gson::toJson), + reblogServerId = reblog?.id, + reblogAccountId = reblog?.let { this.account.id }, + poll = actionable.poll.let(gson::toJson), + muted = actionable.muted + ) +} + +fun Status.lift(): Either = Either.Right(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt new file mode 100644 index 00000000..49655ad8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt @@ -0,0 +1,903 @@ +package com.keylesspalace.tusky.components.timeline + +import android.content.SharedPreferences +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.viewdata.StatusViewData +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject + +class TimelineViewModel @Inject constructor( + private val timelineRepo: TimelineRepository, + private val timelineCases: TimelineCases, + private val api: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager, + private val sharedPreferences: SharedPreferences, + private val filterModel: FilterModel, +) : RxAwareViewModel() { + + enum class FailureReason { + NETWORK, + OTHER, + } + + val viewUpdates: Observable + get() = updateViewSubject + + var kind: Kind = Kind.HOME + private set + + var isLoadingInitially = false + private set + var isRefreshing = false + private set + var bottomLoading = false + private set + var initialUpdateFailed = false + private set + var failure: FailureReason? = null + private set + var id: String? = null + private set + var tags: List = emptyList() + private set + + private var alwaysShowSensitiveMedia = false + private var alwaysOpenSpoilers = false + private var filterRemoveReplies = false + private var filterRemoveReblogs = false + private var didLoadEverythingBottom = false + + private var updateViewSubject = PublishSubject.create() + + /** + * For some timeline kinds we must use LINK headers and not just status ids. + */ + private var nextId: String? = null + + val statuses = mutableListOf() + + fun init( + kind: Kind, + id: String?, + tags: List + ) { + this.kind = kind + this.id = id + this.tags = tags + + if (kind == Kind.HOME) { + filterRemoveReplies = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + filterRemoveReblogs = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + } + this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + + viewModelScope.launch { + eventHub.events + .asFlow() + .collect { event -> handleEvent(event) } + } + + reloadFilters() + } + + private suspend fun updateCurrent() { + val topId = statuses.firstIsInstanceOrNull()?.id ?: return + // Request statuses including current top to refresh all of them + val topIdMinusOne = topId.inc() + val statuses = try { + loadStatuses( + maxId = topIdMinusOne, + sinceId = null, + sinceIdMinusOne = null, + TimelineRequestMode.NETWORK, + ) + } catch (t: Exception) { + initialUpdateFailed = true + if (isExpectedRequestException(t)) { + Log.d(TAG, "Failed updating timeline", t) + triggerViewUpdate() + return + } else { + throw t + } + } + + initialUpdateFailed = false + + // When cached timeline is too old, we would replace it with nothing + if (statuses.isNotEmpty()) { + val mutableStatuses = statuses.toMutableList() + filterStatuses(mutableStatuses) + this.statuses.removeAll { item -> + val id = when (item) { + is StatusViewData.Concrete -> item.id + is StatusViewData.Placeholder -> item.id + } + + id == topId || id.isLessThan(topId) + } + this.statuses.addAll(mutableStatuses.toViewData()) + } + triggerViewUpdate() + } + + private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException + + fun refresh(): Job { + return viewModelScope.launch { + isRefreshing = true + failure = null + triggerViewUpdate() + + try { + if (initialUpdateFailed) updateCurrent() + loadAbove() + } catch (e: Exception) { + if (isExpectedRequestException(e)) { + Log.e(TAG, "Failed to refresh", e) + } else { + throw e + } + } finally { + isRefreshing = false + triggerViewUpdate() + } + } + } + + /** When reaching the end of list. WIll optionally show spinner in the end of list. */ + fun loadMore(): Job { + return viewModelScope.launch { + if (didLoadEverythingBottom || bottomLoading) { + return@launch + } + if (statuses.isEmpty()) { + loadInitial().join() + return@launch + } + setLoadingPlaceholderBelow() + + val bottomId: String? = + if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { + nextId + } else { + statuses.lastOrNull { it is StatusViewData.Concrete } + ?.let { (it as StatusViewData.Concrete).id } + } + try { + loadBelow(bottomId) + } catch (e: Exception) { + if (isExpectedRequestException(e)) { + if (statuses.lastOrNull() is StatusViewData.Placeholder) { + statuses.removeAt(statuses.lastIndex) + } + } else { + throw e + } + } finally { + triggerViewUpdate() + } + } + } + + /** Load and insert statuses below the [bottomId]. Does not indicate progress. */ + private suspend fun loadBelow(bottomId: String?) { + this.bottomLoading = true + try { + val statuses = loadStatuses( + bottomId, + null, + null, + TimelineRequestMode.ANY + ) + addStatusesBelow(statuses.toMutableList()) + } finally { + this.bottomLoading = false + } + } + + private fun setLoadingPlaceholderBelow() { + val last = statuses.last() + val placeholder: StatusViewData.Placeholder + if (last is StatusViewData.Concrete) { + val placeholderId = last.id.dec() + placeholder = StatusViewData.Placeholder(placeholderId, true) + statuses.add(placeholder) + } else { + placeholder = last as StatusViewData.Placeholder + } + statuses[statuses.lastIndex] = placeholder + triggerViewUpdate() + } + + private fun addStatusesBelow(statuses: MutableList>) { + val fullFetch = isFullFetch(statuses) + // Remove placeholder in the bottom if it's there + if (this.statuses.isNotEmpty() + && this.statuses.last() !is StatusViewData.Concrete + ) { + this.statuses.removeAt(this.statuses.lastIndex) + } + + // Removing placeholder if it's the last one from the cache + if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { + statuses.removeAt(statuses.size - 1) + } + + val oldSize = this.statuses.size + if (this.statuses.isNotEmpty()) { + addItems(statuses) + } else { + updateStatuses(statuses, 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 + } + } + + fun loadGap(position: Int): Job { + return viewModelScope.launch { + //check bounds before accessing list, + if (statuses.size < position || position <= 0) { + Log.e(TAG, "Wrong gap position: $position") + return@launch + } + + val fromStatus = statuses[position - 1].asStatusOrNull() + val toStatus = statuses[position + 1].asStatusOrNull() + val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id + if (fromStatus == null || toStatus == null) { + Log.e(TAG, "Failed to load more at $position, wrong placeholder position") + return@launch + } + val placeholder = statuses[position].asPlaceholderOrNull() ?: run { + Log.e(TAG, "Not a placeholder at $position") + return@launch + } + + val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true) + statuses[position] = newViewData + triggerViewUpdate() + + try { + val statuses = loadStatuses( + fromStatus.id, + toStatus.id, + toMinusOne, + TimelineRequestMode.NETWORK + ) + replacePlaceholderWithStatuses( + statuses.toMutableList(), + isFullFetch(statuses), + position + ) + } catch (t: Exception) { + if (isExpectedRequestException(t)) { + Log.e(TAG, "Failed to load gap", t) + if (statuses[position] is StatusViewData.Placeholder) { + statuses[position] = StatusViewData.Placeholder(placeholder.id, false) + } + } else { + throw t + } + } + } + } + + fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + try { + timelineCases.reblog(status.id, reblog).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to reblog status " + status.id, t) + + } + } + } + + fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + + try { + timelineCases.favourite(status.id, favorite).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.id, t) + } + } + } + + fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + try { + timelineCases.bookmark(status.id, bookmark).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.id, t) + } + } + } + + fun voteInPoll(position: Int, choices: List): Job = viewModelScope.launch { + val status = statuses[position].asStatusOrNull() ?: return@launch + + val poll = status.status.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } + + val votedPoll = poll.votedCopy(choices) + updatePoll(status, votedPoll) + + try { + timelineCases.voteInPoll(status.id, poll.id, choices).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.id, t) + } + } + } + + private fun updatePoll( + status: StatusViewData.Concrete, + newPoll: Poll + ) { + updateStatusById(status.id) { + it.copy(status = it.status.copy(poll = newPoll)) + } + } + + fun changeExpanded(expanded: Boolean, position: Int) { + updateStatusAt(position) { it.copy(isExpanded = expanded) } + triggerViewUpdate() + } + + fun changeContentHidden(isShowing: Boolean, position: Int) { + updateStatusAt(position) { it.copy(isShowingContent = isShowing) } + triggerViewUpdate() + } + + fun changeContentCollapsed(isCollapsed: Boolean, position: Int) { + updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) } + triggerViewUpdate() + } + + private fun removeAllByAccountId(accountId: String) { + statuses.removeAll { vm -> + val status = vm.asStatusOrNull()?.status ?: return@removeAll false + status.account.id == accountId || status.actionableStatus.account.id == accountId + } + } + + private fun removeAllByInstance(instance: String) { + statuses.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + LinkHelper.getDomain(status.account.url) == instance + } + } + + private fun triggerViewUpdate() { + this.updateViewSubject.onNext(Unit) + } + + private suspend fun loadStatuses( + maxId: String?, + sinceId: String?, + sinceIdMinusOne: String?, + homeMode: TimelineRequestMode, + ): List { + val statuses = if (kind == Kind.HOME) { + timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode) + .await() + } else { + val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await() + if (response.isSuccessful) { + val newNextId = extractNextId(response) + if (newNextId != null) { + // when we reach the bottom of the list, we won't have a new link. If + // we blindly write `null` here we will start loading from the top + // again. + nextId = newNextId + } + response.body()?.map { Either.Right(it) } ?: listOf() + } else { + throw Exception(response.message()) + } + } + + filterStatuses(statuses.toMutableList()) + + return statuses + } + + private fun updateStatuses( + newStatuses: MutableList>, + fullFetch: Boolean + ) { + if (statuses.isEmpty()) { + statuses.addAll(newStatuses.toViewData()) + } else { + val lastOfNew = newStatuses.lastOrNull() + val index = if (lastOfNew == null) -1 + else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id } + if (index >= 0) { + statuses.subList(0, index).clear() + } + + val newIndex = + newStatuses.indexOfFirst { + it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id + } + if (newIndex == -1) { + if (index == -1 && fullFetch) { + val placeholderId = + newStatuses.last { status -> status.isRight() }.asRight().id.inc() + newStatuses.add(Either.Left(Placeholder(placeholderId))) + } + statuses.addAll(0, newStatuses.toViewData()) + } else { + statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData()) + } + } + // Remove all consecutive placeholders + removeConsecutivePlaceholders() + this.triggerViewUpdate() + } + + private fun filterViewData(viewData: MutableList) { + viewData.removeAll { vd -> + vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false + } + } + + private fun filterStatuses(statuses: MutableList>) { + statuses.removeAll { status -> + status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false + } + } + + private fun shouldFilterStatus(status: Status): Boolean { + return status.inReplyToId != null && filterRemoveReplies + || status.reblog != null && filterRemoveReblogs + || filterModel.shouldFilterStatus(status.actionableStatus) + } + + private fun extractNextId(response: Response<*>): String? { + val linkHeader = response.headers()["Link"] ?: return null + val links = HttpHeaderLink.parse(linkHeader) + val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null + val nextLink = nextHeader.uri ?: return null + return nextLink.getQueryParameter("max_id") + } + + private suspend fun tryCache() { + // Request timeline from disk to make it quick, then replace it with timeline from + // the server to update it + val statuses = + timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) + .await() + + val mutableStatusResponse = statuses.toMutableList() + filterStatuses(mutableStatusResponse) + if (statuses.size > 1) { + clearPlaceholdersForResponse(mutableStatusResponse) + this.statuses.clear() + this.statuses.addAll(statuses.toViewData()) + } + } + + fun loadInitial(): Job { + return viewModelScope.launch { + if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) { + return@launch + } + isLoadingInitially = true + failure = null + triggerViewUpdate() + + if (kind == Kind.HOME) { + tryCache() + isLoadingInitially = statuses.isEmpty() + updateCurrent() + try { + loadAbove() + } catch (e: Exception) { + Log.e(TAG, "Loading above failed", e) + if (!isExpectedRequestException(e)) { + throw e + } else if (statuses.isEmpty()) { + failure = + if (e is IOException) FailureReason.NETWORK + else FailureReason.OTHER + } + } finally { + isLoadingInitially = false + triggerViewUpdate() + } + } else { + try { + loadBelow(null) + } catch (e: IOException) { + failure = FailureReason.NETWORK + } catch (e: HttpException) { + failure = FailureReason.OTHER + } finally { + isLoadingInitially = false + triggerViewUpdate() + } + } + } + } + + private suspend fun loadAbove() { + var firstOrNull: String? = null + var secondOrNull: String? = null + for (i in statuses.indices) { + val status = statuses[i].asStatusOrNull() ?: continue + firstOrNull = status.id + secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id + break + } + + try { + if (firstOrNull != null) { + triggerViewUpdate() + + val statuses = loadStatuses( + maxId = null, + sinceId = firstOrNull, + sinceIdMinusOne = secondOrNull, + homeMode = TimelineRequestMode.NETWORK + ) + + val fullFetch = isFullFetch(statuses) + updateStatuses(statuses.toMutableList(), fullFetch) + } else { + loadBelow(null) + } + } finally { + triggerViewUpdate() + } + } + + private fun isFullFetch(statuses: List) = statuses.size >= LOAD_AT_ONCE + + private fun fullyRefresh(): Job { + this.statuses.clear() + return loadInitial() + } + + private fun fetchStatusesForKind( + fromId: String?, + uptoId: String?, + limit: Int + ): Single>> { + return when (kind) { + Kind.HOME -> api.homeTimeline(fromId, uptoId, limit) + Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) + Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) + Kind.TAG -> { + val firstHashtag = tags[0] + val additionalHashtags = tags.subList(1, tags.size) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) + } + Kind.USER -> api.accountStatuses( + id!!, + fromId, + uptoId, + limit, + excludeReplies = true, + onlyMedia = null, + pinned = null + ) + Kind.USER_PINNED -> api.accountStatuses( + id!!, + fromId, + uptoId, + limit, + excludeReplies = null, + onlyMedia = null, + pinned = true + ) + Kind.USER_WITH_REPLIES -> api.accountStatuses( + id!!, + fromId, + uptoId, + limit, + excludeReplies = null, + onlyMedia = null, + pinned = null + ) + Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) + Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) + Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) + } + } + + private fun replacePlaceholderWithStatuses( + newStatuses: MutableList>, + fullFetch: Boolean, pos: Int + ) { + val placeholder = statuses[pos] + if (placeholder is StatusViewData.Placeholder) { + statuses.removeAt(pos) + } + if (newStatuses.isEmpty()) { + return + } + val newViewData = newStatuses + .toViewData() + .toMutableList() + + if (fullFetch) { + newViewData.add(placeholder) + } + statuses.addAll(pos, newViewData) + removeConsecutivePlaceholders() + triggerViewUpdate() + } + + private fun removeConsecutivePlaceholders() { + for (i in 0 until statuses.size - 1) { + if (statuses[i] is StatusViewData.Placeholder && + statuses[i + 1] is StatusViewData.Placeholder + ) { + statuses.removeAt(i) + } + } + } + + private fun addItems(newStatuses: List>) { + if (newStatuses.isEmpty()) { + return + } + statuses.addAll(newStatuses.toViewData()) + removeConsecutivePlaceholders() + } + + /** + * For certain requests we don't want to see placeholders, they will be removed some other way + */ + private fun clearPlaceholdersForResponse(statuses: MutableList>) { + statuses.removeAll { status -> status.isLeft() } + } + + private fun handleReblogEvent(reblogEvent: ReblogEvent) { + updateStatusById(reblogEvent.statusId) { + it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) + } + } + + private fun handleFavEvent(favEvent: FavoriteEvent) { + updateStatusById(favEvent.statusId) { + it.copy(status = it.status.copy(favourited = favEvent.favourite)) + } + } + + private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { + updateStatusById(bookmarkEvent.statusId) { + it.copy(status = it.status.copy(bookmarked = bookmarkEvent.bookmark)) + } + } + + private fun handlePinEvent(pinEvent: PinEvent) { + updateStatusById(pinEvent.statusId) { + it.copy(status = it.status.copy(pinned = pinEvent.pinned)) + } + } + + private fun handleStatusComposeEvent(status: Status) { + when (kind) { + Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> refresh() + Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) { + refresh() + } else { + return + } + Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return + } + } + + private fun deleteStatusById(id: String) { + for (i in statuses.indices) { + val either = statuses[i] + if (either.asStatusOrNull()?.id == id) { + statuses.removeAt(i) + break + } + } + } + + private fun onPreferenceChanged(key: String) { + when (key) { + PrefKeys.TAB_FILTER_HOME_REPLIES -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + val oldRemoveReplies = filterRemoveReplies + filterRemoveReplies = kind == Kind.HOME && !filter + if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) { + fullyRefresh() + } + } + PrefKeys.TAB_FILTER_HOME_BOOSTS -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + val oldRemoveReblogs = filterRemoveReblogs + filterRemoveReblogs = kind == Kind.HOME && !filter + if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) { + fullyRefresh() + } + } + Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { + if (filterContextMatchesKind(kind, listOf(key))) { + reloadFilters() + } + } + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { + // it is ok if only newly loaded statuses are affected, no need to fully refresh + alwaysShowSensitiveMedia = + accountManager.activeAccount!!.alwaysShowSensitiveMedia + } + } + } + + // public for now + fun filterContextMatchesKind( + kind: Kind, + filterContext: List + ): Boolean { + // home, notifications, public, thread + return when (kind) { + Kind.HOME, Kind.LIST -> filterContext.contains( + Filter.HOME + ) + Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( + Filter.PUBLIC + ) + Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( + Filter.NOTIFICATIONS + ) + Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( + Filter.ACCOUNT + ) + else -> false + } + } + + private fun handleEvent(event: Event) { + when (event) { + is FavoriteEvent -> handleFavEvent(event) + is ReblogEvent -> handleReblogEvent(event) + is BookmarkEvent -> handleBookmarkEvent(event) + is PinEvent -> handlePinEvent(event) + is MuteConversationEvent -> fullyRefresh() + is UnfollowEvent -> { + if (kind == Kind.HOME) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is BlockEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is MuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is DomainMuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val instance = event.instance + removeAllByInstance(instance) + } + } + is StatusDeletedEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.statusId + deleteStatusById(id) + } + } + is StatusComposedEvent -> { + val status = event.status + handleStatusComposeEvent(status) + } + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + + private inline fun updateStatusById( + id: String, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id } + if (pos == -1) return + updateStatusAt(pos, updater) + } + + private inline fun updateStatusAt( + position: Int, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { + val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return + statuses[position] = updater(status) + triggerViewUpdate() + } + + private fun List.toViewData(): List = this.map { + when (it) { + is Either.Right -> it.value.toViewData( + alwaysShowSensitiveMedia, + alwaysOpenSpoilers + ) + is Either.Left -> StatusViewData.Placeholder(it.value.id, false) + } + } + + private fun reloadFilters() { + viewModelScope.launch { + val filters = try { + api.getFilters().await() + } catch (t: Exception) { + Log.e(TAG, "Failed to fetch filters", t) + return@launch + } + filterModel.initWithFilters(filters.filter { + filterContextMatchesKind(kind, it.context) + }) + filterViewData(this@TimelineViewModel.statuses) + } + } + + private inline fun ifExpected( + t: Exception, + cb: () -> Unit + ) { + if (isExpectedRequestException(t)) { + cb() + } else { + throw t + } + } + + + companion object { + private const val TAG = "TimelineVM" + internal const val LOAD_AT_ONCE = 30 + } + + enum class Kind { + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index b81ad63e..48792a1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -105,13 +105,13 @@ class Converters @Inject constructor ( } @TypeConverter - fun mentionArrayToJson(mentionArray: Array?): String? { + fun mentionListToJson(mentionArray: List?): String? { return gson.toJson(mentionArray) } @TypeConverter - fun jsonToMentionArray(mentionListJson: String?): Array? { - return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + fun jsonToMentionArray(mentionListJson: String?): List? { + return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) } @TypeConverter diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index a1a8c8fe..16ed59cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt index af8cfb88..5086c752 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt @@ -4,8 +4,8 @@ import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.TimelineRepository -import com.keylesspalace.tusky.repository.TimelineRepositoryImpl +import com.keylesspalace.tusky.components.timeline.TimelineRepository +import com.keylesspalace.tusky.components.timeline.TimelineRepositoryImpl import dagger.Module import dagger.Provides diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index ce83deda..a71817ec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -11,6 +11,8 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.search.SearchViewModel +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.viewmodel.AccountViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel @@ -97,5 +99,10 @@ abstract class ViewModelModule { @ViewModelKey(DraftsViewModel::class) internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(TimelineViewModel::class) + internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel + //Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 0dbefd61..cb4ce3cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -22,10 +22,11 @@ import com.google.gson.JsonParseException import com.google.gson.annotations.JsonAdapter data class Notification( - val type: Type, - val id: String, - val account: Account, - val status: Status?) { + val type: Type, + val id: String, + val account: Account, + val status: Status? +) { @JsonAdapter(NotificationTypeAdapter::class) enum class Type(val presentation: String) { @@ -71,18 +72,25 @@ data class Notification( class NotificationTypeAdapter : JsonDeserializer { @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type { + override fun deserialize( + json: JsonElement, + typeOfT: java.lang.reflect.Type, + context: JsonDeserializationContext + ): Type { return Type.byString(json.asString) } } - + + /** Helper for Java */ + fun copyWithStatus(status: Status?): Notification = copy(status = status) + // for Pleroma compatibility that uses Mention type - fun rewriteToStatusTypeIfNeeded(accountId: String) : Notification { + fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { return if (status.mentions.any { - it.id == accountId - }) this else copy(type = Type.STATUS) + it.id == accountId + }) this else copy(type = Type.STATUS) } return this } 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 aee30e8d..728cc1b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -22,8 +22,8 @@ import com.google.gson.annotations.SerializedName import java.util.* data class Status( - var id: String, - var url: String?, // not present if it's reblog + val id: String, + val url: String?, // not present if it's reblog val account: Account, @SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @@ -40,10 +40,10 @@ data class Status( @SerializedName("spoiler_text") val spoilerText: String, val visibility: Visibility, @SerializedName("media_attachments") var attachments: ArrayList, - val mentions: Array, + val mentions: List, val application: Application?, - var pinned: Boolean?, - var muted: Boolean?, + val pinned: Boolean?, + val muted: Boolean?, val poll: Poll?, val card: Card? ) { @@ -54,6 +54,11 @@ data class Status( val actionableStatus: Status get() = reblog ?: this + /** Helper for Java */ + fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) + + /** Helper for Java */ + fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) enum class Visibility(val num: Int) { UNKNOWN(0), 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 deec5888..89f1efcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -58,6 +58,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent; import com.keylesspalace.tusky.appstore.BookmarkEvent; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.FavoriteEvent; +import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; import com.keylesspalace.tusky.db.AccountEntity; @@ -83,6 +84,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.BackgroundMessageView; import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; @@ -92,6 +94,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -311,35 +314,6 @@ public class NotificationsFragment extends SFragment implements .show(); } - private void handleFavEvent(FavoriteEvent event) { - Pair posAndNotification = - findReplyPosition(event.getStatusId()); - if (posAndNotification == null) return; - //noinspection ConstantConditions - setFavouriteForStatus(posAndNotification.first, - posAndNotification.second.getStatus(), - event.getFavourite()); - } - - private void handleBookmarkEvent(BookmarkEvent event) { - Pair posAndNotification = - findReplyPosition(event.getStatusId()); - if (posAndNotification == null) return; - //noinspection ConstantConditions - setBookmarkForStatus(posAndNotification.first, - posAndNotification.second.getStatus(), - event.getBookmark()); - } - - 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); @@ -386,11 +360,13 @@ public class NotificationsFragment extends SFragment implements .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe(event -> { if (event instanceof FavoriteEvent) { - handleFavEvent((FavoriteEvent) event); + setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); } else if (event instanceof BookmarkEvent) { - handleBookmarkEvent((BookmarkEvent) event); + setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); } else if (event instanceof ReblogEvent) { - handleReblogEvent((ReblogEvent) event); + setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); + } else if (event instanceof PinEvent) { + setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); } else if (event instanceof BlockEvent) { removeAllByAccountId(((BlockEvent) event).getAccountId()); } else if (event instanceof PreferenceChangedEvent) { @@ -423,34 +399,21 @@ public class NotificationsFragment extends SFragment implements final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblog(status, reblog) + timelineCases.reblog(status.getId(), reblog) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> setReblogForStatus(position, status, reblog), + (newStatus) -> setReblogForStatus(status.getId(), reblog), (t) -> Log.d(getClass().getSimpleName(), "Failed to reblog status: " + status.getId(), t) ); } - private void setReblogForStatus(int position, Status status, boolean reblog) { - status.setReblogged(reblog); - - if (status.getReblog() != null) { - 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()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setReblogForStatus(String statusId, boolean reblog) { + updateStatus(statusId, (s) -> { + s.setReblogged(reblog); + return s; + }); } @Override @@ -458,34 +421,21 @@ public class NotificationsFragment extends SFragment implements final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); - timelineCases.favourite(status, favourite) + timelineCases.favourite(status.getId(), favourite) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> setFavouriteForStatus(position, status, favourite), + (newStatus) -> setFavouriteForStatus(status.getId(), favourite), (t) -> Log.d(getClass().getSimpleName(), "Failed to favourite status: " + status.getId(), t) ); } - private void setFavouriteForStatus(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()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setFavouriteForStatus(String statusId, boolean favourite) { + updateStatus(statusId, (s) -> { + s.setFavourited(favourite); + return s; + }); } @Override @@ -493,63 +443,38 @@ public class NotificationsFragment extends SFragment implements final Notification notification = notifications.get(position).asRight(); final Status status = notification.getStatus(); - timelineCases.bookmark(status, bookmark) + timelineCases.bookmark(status.getActionableId(), bookmark) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> setBookmarkForStatus(position, status, bookmark), + (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), (t) -> Log.d(getClass().getSimpleName(), "Failed to bookmark status: " + status.getId(), t) ); } - private void setBookmarkForStatus(int position, Status status, boolean bookmark) { - status.setBookmarked(bookmark); - - if (status.getReblog() != null) { - status.getReblog().setBookmarked(bookmark); - } - - NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setBookmarked(bookmark); - - NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( - viewdata.getType(), viewdata.getId(), viewdata.getAccount(), - viewDataBuilder.createStatusViewData()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setBookmarkForStatus(String statusId, boolean bookmark) { + updateStatus(statusId, (s) -> { + s.setBookmarked(bookmark); + return s; + }); } public void onVoteInPoll(int position, @NonNull List choices) { final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.voteInPoll(status, choices) + final Status status = notification.getStatus().getActionableStatus(); + timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newPoll) -> setVoteForPoll(position, newPoll), + (newPoll) -> setVoteForPoll(status, newPoll), (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) ); } - private void setVoteForPoll(int position, Poll poll) { - - NotificationViewData.Concrete viewdata = (NotificationViewData.Concrete) notifications.getPairedItem(position); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder(viewdata.getStatusViewData()); - viewDataBuilder.setPoll(poll); - - NotificationViewData.Concrete newViewData = new NotificationViewData.Concrete( - viewdata.getType(), viewdata.getId(), viewdata.getAccount(), - viewDataBuilder.createStatusViewData()); - - notifications.setPairedItem(position, newViewData); - updateAdapter(); + private void setVoteForPoll(Status status, Poll poll) { + updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); } @Override @@ -562,13 +487,17 @@ public class NotificationsFragment extends SFragment implements public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { Notification notification = notifications.get(position).asRightOrNull(); if (notification == null || notification.getStatus() == null) return; - super.viewMedia(attachmentIndex, notification.getStatus(), view); + Status status = notification.getStatus(); + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); } @Override public void onViewThread(int position) { Notification notification = notifications.get(position).asRight(); - super.viewThread(notification.getStatus()); + Status status = notification.getStatus(); + if (status == null) return; + ; + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); } @Override @@ -579,30 +508,19 @@ public class NotificationsFragment extends SFragment implements @Override public void onExpandedChange(boolean expanded, int position) { - NotificationViewData.Concrete old = - (NotificationViewData.Concrete) notifications.getPairedItem(position); - StatusViewData.Concrete statusViewData = - new StatusViewData.Builder(old.getStatusViewData()) - .setIsExpanded(expanded) - .createStatusViewData(); - NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), - old.getId(), old.getAccount(), statusViewData); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); + updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); } @Override public void onContentHiddenChange(boolean isShowing, int position) { - NotificationViewData.Concrete old = - (NotificationViewData.Concrete) notifications.getPairedItem(position); - StatusViewData.Concrete statusViewData = - new StatusViewData.Builder(old.getStatusViewData()) - .setIsShowingSensitiveContent(isShowing) - .createStatusViewData(); - NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(), - old.getId(), old.getAccount(), statusViewData); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); + updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); + } + + private void setPinForStatus(String statusId, boolean pinned) { + updateStatus(statusId, status -> { + status.copyWithPinned(pinned); + return status; + }); } @Override @@ -628,42 +546,74 @@ public class NotificationsFragment extends SFragment implements @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if (position < 0 || position >= notifications.size()) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1)); - return; - } + updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed)); + ; + } - NotificationViewData notification = notifications.getPairedItem(position); - if (!(notification instanceof NotificationViewData.Concrete)) { - Log.e(TAG, String.format( - "Expected NotificationViewData.Concrete, got %s instead at position: %d of %d", - notification == null ? "null" : notification.getClass().getSimpleName(), + private void updateStatus(String statusId, Function mapper) { + int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && + s.asRight().getStatus() != null && + s.asRight().getStatus().getId().equals(statusId)); + if (index == -1) return; + + // We have quite some graph here: + // + // Notification --------> Status + // ^ + // | + // StatusViewData + // ^ + // | + // NotificationViewData -----+ + // + // So if we have "new" status we need to update all references to be sure that data is + // up-to-date: + // 1. update status + // 2. update notification + // 3. update statusViewData + // 4. update notificationViewData + + Status oldStatus = notifications.get(index).asRight().getStatus(); + NotificationViewData.Concrete oldViewData = + (NotificationViewData.Concrete) this.notifications.getPairedItem(index); + Status newStatus = mapper.apply(oldStatus); + Notification newNotification = this.notifications.get(index).asRight() + .copyWithStatus(newStatus); + StatusViewData.Concrete newStatusViewData = + Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); + NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); + + notifications.set(index, new Either.Right<>(newNotification)); + notifications.setPairedItem(index, newViewData); + + updateAdapter(); + } + + private void updateViewDataAt(int position, + Function mapper) { + if (position < 0 || position >= notifications.size()) { + String message = String.format( + Locale.getDefault(), + "Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1 - )); + ); + Log.e(TAG, message); return; } + NotificationViewData someViewData = this.notifications.getPairedItem(position); + if (!(someViewData instanceof NotificationViewData.Concrete)) { + return; + } + NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; + StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); + if (oldStatusViewData == null) return; - StatusViewData.Concrete status = ((NotificationViewData.Concrete) notification).getStatusViewData(); - StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) - .setCollapsed(isCollapsed) - .createStatusViewData(); + NotificationViewData.Concrete newViewData = + oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); + notifications.setPairedItem(position, newViewData); - NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification; - NotificationViewData updatedNotification = new NotificationViewData.Concrete( - concreteNotification.getType(), - concreteNotification.getId(), - concreteNotification.getAccount(), - updatedStatus - ); - notifications.setPairedItem(position, updatedNotification); updateAdapter(); - - // Since we cannot notify to the RecyclerView right away because it may be scrolling - // we run this when the RecyclerView is done doing measurements and other calculations. - // To test this is not bs: try getting a notification while scrolling, without wrapping - // notifyItemChanged in a .post() call. App will crash. - recyclerView.post(() -> adapter.notifyItemChanged(position, notification)); } @Override @@ -844,8 +794,11 @@ public class NotificationsFragment extends SFragment implements for (Either either : notifications) { Notification notification = either.asRightOrNull(); if (notification != null && notification.getId().equals(notificationId)) { - super.viewThread(notification.getStatus()); - return; + Status status = notification.getStatus(); + if (status != null) { + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); + return; + } } } Log.w(TAG, "Didn't find a notification for ID: " + notificationId); @@ -951,7 +904,7 @@ public class NotificationsFragment extends SFragment implements } Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .observeOn(AndroidSchedulers.mainThread()) + .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( response -> { 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 3ae843c5..f7dd8159 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -24,7 +24,6 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Environment; -import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -33,7 +32,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.PopupMenu; import androidx.core.app.ActivityOptionsCompat; @@ -55,8 +53,6 @@ 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.Filter; -import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; @@ -64,20 +60,14 @@ import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.inject.Inject; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import kotlin.Unit; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; import static autodispose2.AutoDispose.autoDisposable; import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; @@ -96,11 +86,6 @@ public abstract class SFragment extends Fragment implements Injectable { private BottomSheetActivity bottomSheetActivity; - private static List filters; - private boolean filterRemoveRegex; - private Matcher filterRemoveRegexMatcher; - private static final Matcher alphanumeric = Pattern.compile("^\\w+$").matcher(""); - @Inject public MastodonApi mastodonApi; @Inject @@ -131,9 +116,8 @@ public abstract class SFragment extends Fragment implements Injectable { bottomSheetActivity.viewAccount(status.getAccount().getId()); } - protected void viewThread(Status status) { - Status actionableStatus = status.getActionableStatus(); - bottomSheetActivity.viewThread(actionableStatus.getId(), actionableStatus.getUrl()); + protected void viewThread(String statusId, @Nullable String statusUrl) { + bottomSheetActivity.viewThread(statusId, statusUrl); } protected void viewAccount(String accountId) { @@ -149,7 +133,7 @@ public abstract class SFragment extends Fragment implements Injectable { Status actionableStatus = status.getActionableStatus(); Status.Visibility replyVisibility = actionableStatus.getVisibility(); String contentWarning = actionableStatus.getSpoilerText(); - Status.Mention[] mentions = actionableStatus.getMentions(); + List mentions = actionableStatus.getMentions(); Set mentionedUsernames = new LinkedHashSet<>(); mentionedUsernames.add(actionableStatus.getAccount().getUsername()); String loggedInUsername = null; @@ -316,11 +300,11 @@ public abstract class SFragment extends Fragment implements Injectable { return true; } case R.id.pin: { - timelineCases.pin(status, !status.isPinned()); + timelineCases.pin(status.getId(), !status.isPinned()); return true; } case R.id.status_mute_conversation: { - timelineCases.muteConversation(status, status.getMuted() == null || !status.getMuted()) + timelineCases.muteConversation(status.getId(), status.getMuted() == null || !status.getMuted()) .onErrorReturnItem(status) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) @@ -335,12 +319,12 @@ public abstract class SFragment extends Fragment implements Injectable { private void onMute(String accountId, String accountUsername) { MuteAccountDialog.showMuteAccountDialog( - this.getActivity(), - accountUsername, - (notifications, duration) -> { - timelineCases.mute(accountId, notifications, duration); - return Unit.INSTANCE; - } + this.getActivity(), + accountUsername, + (notifications, duration) -> { + timelineCases.mute(accountId, notifications, duration); + return Unit.INSTANCE; + } ); } @@ -352,7 +336,7 @@ public abstract class SFragment extends Fragment implements Injectable { .show(); } - private static boolean accountIsInMentions(AccountEntity account, Status.Mention[] mentions) { + private static boolean accountIsInMentions(AccountEntity account, List mentions) { if (account == null) { return false; } @@ -368,20 +352,18 @@ public abstract class SFragment extends Fragment implements Injectable { return false; } - protected void viewMedia(int urlIndex, Status status, @Nullable View view) { - final Status actionable = status.getActionableStatus(); - final Attachment active = actionable.getAttachments().get(urlIndex); - Attachment.Type type = active.getType(); + protected void viewMedia(int urlIndex, List attachments, @Nullable View view) { + final AttachmentViewData active = attachments.get(urlIndex); + Attachment.Type type = active.getAttachment().getType(); switch (type) { case GIFV: case VIDEO: case IMAGE: case AUDIO: { - final List attachments = AttachmentViewData.list(actionable); final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments, urlIndex); if (view != null) { - String url = active.getUrl(); + String url = active.getAttachment().getUrl(); ViewCompat.setTransitionName(view, url); ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), @@ -394,7 +376,7 @@ public abstract class SFragment extends Fragment implements Injectable { } default: case UNKNOWN: { - LinkHelper.openLink(active.getUrl(), getContext()); + LinkHelper.openLink(active.getAttachment().getUrl(), getContext()); break; } } @@ -510,83 +492,4 @@ public abstract class SFragment extends Fragment implements Injectable { } }); } - - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) - public void reloadFilters(boolean forceRefresh) { - if (filters != null && !forceRefresh) { - applyFilters(forceRefresh); - return; - } - - mastodonApi.getFilters().enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - filters = response.body(); - if (response.isSuccessful() && filters != null) { - applyFilters(forceRefresh); - } else { - Log.e(TAG, "Error getting filters from server"); - } - } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - Log.e(TAG, "Error getting filters from server", t); - } - }); - } - - protected boolean filterIsRelevant(@NonNull Filter filter) { - // Called when building local filter expression - // Override to select relevant filters for your fragment - return false; - } - - protected void refreshAfterApplyingFilters() { - // Called after filters are updated - // Override to refresh your fragment - } - - @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) - public boolean shouldFilterStatus(Status status) { - - if (filterRemoveRegex && status.getPoll() != null) { - for (PollOption option : status.getPoll().getOptions()) { - if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) { - return true; - } - } - } - - return (filterRemoveRegex && (filterRemoveRegexMatcher.reset(status.getActionableStatus().getContent()).find() - || (!status.getSpoilerText().isEmpty() && filterRemoveRegexMatcher.reset(status.getActionableStatus().getSpoilerText()).find()))); - } - - private void applyFilters(boolean refresh) { - List tokens = new ArrayList<>(); - for (Filter filter : filters) { - if (filterIsRelevant(filter)) { - tokens.add(filterToRegexToken(filter)); - } - } - filterRemoveRegex = !tokens.isEmpty(); - if (filterRemoveRegex) { - filterRemoveRegexMatcher = Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE).matcher(""); - } - if (refresh) { - refreshAfterApplyingFilters(); - } - } - - private static String filterToRegexToken(Filter filter) { - String phrase = filter.getPhrase(); - String quotedPhrase = Pattern.quote(phrase); - return (filter.getWholeWord() && alphanumeric.reset(phrase).matches()) ? // "whole word" should only apply to alphanumeric filters, #1543 - String.format("(^|\\W)%s($|\\W)", quotedPhrase) : - quotedPhrase; - } - - public static void flushFilters() { - filters = null; - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt deleted file mode 100644 index bd0378da..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.kt +++ /dev/null @@ -1,1265 +0,0 @@ -/* Copyright 2021 Tusky Contributors - * - * 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.fragment - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.accessibility.AccessibilityManager -import androidx.core.content.ContextCompat -import androidx.core.util.Pair -import androidx.lifecycle.Lifecycle -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListUpdateCallback -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.SimpleItemAnimator -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent -import com.keylesspalace.tusky.BaseActivity -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.adapter.TimelineAdapter -import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent -import com.keylesspalace.tusky.appstore.DomainMuteEvent -import com.keylesspalace.tusky.appstore.Event -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.MuteConversationEvent -import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -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.databinding.FragmentTimelineBinding -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.keylesspalace.tusky.interfaces.RefreshableFragment -import com.keylesspalace.tusky.interfaces.ReselectableFragment -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.repository.Placeholder -import com.keylesspalace.tusky.repository.TimelineRepository -import com.keylesspalace.tusky.repository.TimelineRequestMode -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.CardViewMode -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.Either.Left -import com.keylesspalace.tusky.util.Either.Right -import com.keylesspalace.tusky.util.HttpHeaderLink -import com.keylesspalace.tusky.util.LinkHelper -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate -import com.keylesspalace.tusky.util.PairedList -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.ViewDataUtils -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.inc -import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.EndlessOnScrollListener -import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import retrofit2.Response -import java.io.IOException -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -class TimelineFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable, ReselectableFragment, RefreshableFragment { - - @Inject - lateinit var eventHub: EventHub - - @Inject - lateinit var timelineRepo: TimelineRepository - - @Inject - lateinit var accountManager: AccountManager - - private val binding by viewBinding(FragmentTimelineBinding::bind) - - private var kind: Kind? = null - private var id: String? = null - private var tags: List = emptyList() - - private lateinit var adapter: TimelineAdapter - - private var isSwipeToRefreshEnabled = true - private var isNeedRefresh = false - - private var eventRegistered = false - - /** - * For some timeline kinds we must use LINK headers and not just status ids. - */ - private var nextId: String? = null - private var layoutManager: LinearLayoutManager? = null - private var scrollListener: EndlessOnScrollListener? = null - private var filterRemoveReplies = false - private var filterRemoveReblogs = false - private var hideFab = false - private var bottomLoading = false - private var didLoadEverythingBottom = false - private var alwaysShowSensitiveMedia = false - private var alwaysOpenSpoiler = false - private var initialUpdateFailed = false - - private val statuses = PairedList, StatusViewData> { input -> - val status = input.asRightOrNull() - if (status != null) { - ViewDataUtils.statusToViewData( - status, - alwaysShowSensitiveMedia, - alwaysOpenSpoiler - ) - } else { - val (id1) = input.asLeft() - StatusViewData.Placeholder(id1, false) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val arguments = requireArguments() - kind = Kind.valueOf(arguments.getString(KIND_ARG)!!) - if (kind == Kind.USER || kind == Kind.USER_PINNED || kind == Kind.USER_WITH_REPLIES || kind == Kind.LIST) { - id = arguments.getString(ID_ARG)!! - } - if (kind == Kind.TAG) { - tags = arguments.getStringArrayList(HASHTAGS_ARG)!! - } - - isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - - val preferences = PreferenceManager.getDefaultSharedPreferences(activity) - val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), - showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), - useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), - cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) CardViewMode.INDENTED else CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ) - adapter = TimelineAdapter(dataSource, statusDisplayOptions, this) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_timeline, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setupSwipeRefreshLayout() - setupRecyclerView() - updateAdapter() - setupTimelinePreferences() - if (statuses.isEmpty()) { - binding.progressBar.show() - bottomLoading = true - sendInitialRequest() - } else { - binding.progressBar.hide() - if (isNeedRefresh) { - onRefresh() - } - } - } - - private fun sendInitialRequest() { - if (kind == Kind.HOME) { - tryCache() - } else { - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) - } - } - - private fun tryCache() { - // Request timeline from disk to make it quick, then replace it with timeline from - // the server to update it - timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { statuses: List> -> - val mutableStatusResponse = statuses.toMutableList() - filterStatuses(mutableStatusResponse) - if (statuses.size > 1) { - clearPlaceholdersForResponse(mutableStatusResponse) - this.statuses.clear() - this.statuses.addAll(statuses) - updateAdapter() - binding.progressBar.hide() - // Request statuses including current top to refresh all of them - } - updateCurrent() - loadAbove() - } - } - - private fun updateCurrent() { - if (statuses.isEmpty()) { - return - } - val topId = statuses.first { status -> status.isRight() }!!.asRight().id - timelineRepo.getStatuses(topId, null, null, LOAD_AT_ONCE, - TimelineRequestMode.NETWORK) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { statuses: List> -> - - initialUpdateFailed = false - // When cached timeline is too old, we would replace it with nothing - if (statuses.isNotEmpty()) { - val mutableStatuses = statuses.toMutableList() - filterStatuses(mutableStatuses) - if (!this.statuses.isEmpty()) { - // clear old cached statuses - val iterator = this.statuses.iterator() - while (iterator.hasNext()) { - val item = iterator.next() - if (item.isRight()) { - val (id1) = item.asRight() - if (id1.length < topId.length || id1 < topId) { - iterator.remove() - } - } else { - val (id1) = item.asLeft() - if (id1.length < topId.length || id1 < topId) { - iterator.remove() - } - } - } - } - this.statuses.addAll(mutableStatuses) - updateAdapter() - } - bottomLoading = false - }, - { t: Throwable? -> - Log.d(TAG, "Failed updating timeline", t) - initialUpdateFailed = true - // Indicate that we are not loading anymore - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - }) - } - - private fun setupTimelinePreferences() { - alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler - if (kind == Kind.HOME) { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - filterRemoveReplies = !preferences.getBoolean("tabFilterHomeReplies", true) - filterRemoveReblogs = !preferences.getBoolean("tabFilterHomeBoosts", true) - } - reloadFilters(false) - } - - override fun filterIsRelevant(filter: Filter): Boolean { - return filterContextMatchesKind(kind, filter.context) - } - - override fun refreshAfterApplyingFilters() { - fullyRefresh() - } - - private fun setupSwipeRefreshLayout() { - binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled - binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - } - - private fun setupRecyclerView() { - binding.recyclerView.setAccessibilityDelegateCompat( - ListStatusAccessibilityDelegate(binding.recyclerView, this) - { pos -> statuses.getPairedItemOrNull(pos) } - ) - binding.recyclerView.setHasFixedSize(true) - layoutManager = LinearLayoutManager(context) - binding.recyclerView.layoutManager = layoutManager - val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) - binding.recyclerView.addItemDecoration(divider) - - // CWs are expanded without animation, buttons animate itself, we don't need it basically - (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.adapter = adapter - } - - private fun deleteStatusById(id: String) { - for (i in statuses.indices) { - val either = statuses[i] - if (either.isRight() && id == either.asRight().id) { - statuses.remove(either) - updateAdapter() - break - } - } - if (statuses.isEmpty()) { - showEmptyView() - } - } - - private fun showEmptyView() { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) - } - - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - - /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't - * guaranteed to be set until then. */ - scrollListener = if (actionButtonPresent()) { - /* Use a modified scroll listener that both loads more statuses as it goes, and hides - * the follow button on down-scroll. */ - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - hideFab = preferences.getBoolean("fabHide", false) - object : EndlessOnScrollListener(layoutManager) { - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(view, dx, dy) - val composeButton = (activity as ActionButtonActivity).actionButton - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown) { - composeButton.hide() // hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown) { - composeButton.show() // shows it if we are scrolling up - } - } else if (!composeButton.isShown) { - composeButton.show() - } - } - } - - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - this@TimelineFragment.onLoadMore() - } - } - } else { - // Just use the basic scroll listener to load more statuses. - object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - this@TimelineFragment.onLoadMore() - } - } - }.also { - binding.recyclerView.addOnScrollListener(it) - } - - if (!eventRegistered) { - eventHub.events - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe { event: Event? -> - when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is MuteConversationEvent -> fullyRefresh() - is UnfollowEvent -> { - if (kind == Kind.HOME) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val instance = event.instance - removeAllByInstance(instance) - } - } - is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.statusId - deleteStatusById(id) - } - } - is StatusComposedEvent -> { - val status = event.status - handleStatusComposeEvent(status) - } - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) - } - } - } - eventRegistered = true - } - } - - override fun onRefresh() { - binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled - binding.statusView.hide() - isNeedRefresh = false - if (initialUpdateFailed) { - updateCurrent() - } - loadAbove() - } - - private fun loadAbove() { - var firstOrNull: String? = null - var secondOrNull: String? = null - for (i in statuses.indices) { - val status = statuses[i] - if (status.isRight()) { - firstOrNull = status.asRight().id - if (i + 1 < statuses.size && statuses[i + 1].isRight()) { - secondOrNull = statuses[i + 1].asRight().id - } - break - } - } - if (firstOrNull != null) { - sendFetchTimelineRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1) - } else { - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) - } - } - - override fun onReply(position: Int) { - super.reply(statuses[position].asRight()) - } - - override fun onReblog(reblog: Boolean, position: Int) { - val status = statuses[position].asRight() - timelineCases.reblog(status, reblog) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newStatus: Status -> setRebloggedForStatus(position, newStatus, reblog) } - ) { t: Throwable? -> Log.d(TAG, "Failed to reblog status " + status.id, t) } - } - - private fun setRebloggedForStatus(position: Int, status: Status, reblog: Boolean) { - status.reblogged = reblog - if (status.reblog != null) { - status.reblog.reblogged = reblog - } - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setReblogged(reblog) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onFavourite(favourite: Boolean, position: Int) { - val status = statuses[position].asRight() - timelineCases.favourite(status, favourite) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newStatus: Status -> setFavouriteForStatus(position, newStatus, favourite) }, - { t: Throwable? -> Log.d(TAG, "Failed to favourite status " + status.id, t) } - ) - } - - private fun setFavouriteForStatus(position: Int, status: Status, favourite: Boolean) { - status.favourited = favourite - if (status.reblog != null) { - status.reblog.favourited = favourite - } - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setFavourited(favourite) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onBookmark(bookmark: Boolean, position: Int) { - val status = statuses[position].asRight() - timelineCases.bookmark(status, bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newStatus: Status -> setBookmarkForStatus(position, newStatus, bookmark) }, - { t: Throwable? -> Log.d(TAG, "Failed to favourite status " + status.id, t) } - ) - } - - private fun setBookmarkForStatus(position: Int, status: Status, bookmark: Boolean) { - status.bookmarked = bookmark - if (status.reblog != null) { - status.reblog.bookmarked = bookmark - } - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setBookmarked(bookmark) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onVoteInPoll(position: Int, choices: List) { - val status = statuses[position].asRight() - val votedPoll = status.actionableStatus.poll!!.votedCopy(choices) - setVoteForPoll(position, status, votedPoll) - timelineCases.voteInPoll(status, choices) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { newPoll: Poll -> setVoteForPoll(position, status, newPoll) }, - { t: Throwable? -> Log.d(TAG, "Failed to vote in poll: " + status.id, t) } - ) - } - - private fun setVoteForPoll(position: Int, status: Status, newPoll: Poll) { - val actual = findStatusAndPosition(position, status) ?: return - val newViewData: StatusViewData = StatusViewData.Builder(actual.first) - .setPoll(newPoll) - .createStatusViewData() - statuses.setPairedItem(actual.second!!, newViewData) - updateAdapter() - } - - override fun onMore(view: View, position: Int) { - super.more(statuses[position].asRight(), view, position) - } - - override fun onOpenReblog(position: Int) { - super.openReblog(statuses[position].asRight()) - } - - override fun onExpandedChange(expanded: Boolean, position: Int) { - val newViewData: StatusViewData = StatusViewData.Builder( - statuses.getPairedItem(position) as StatusViewData.Concrete) - .setIsExpanded(expanded).createStatusViewData() - statuses.setPairedItem(position, newViewData) - updateAdapter() - } - - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val newViewData: StatusViewData = StatusViewData.Builder( - statuses.getPairedItem(position) as StatusViewData.Concrete) - .setIsShowingSensitiveContent(isShowing).createStatusViewData() - statuses.setPairedItem(position, newViewData) - updateAdapter() - } - - override fun onShowReblogs(position: Int) { - val statusId = statuses[position].asRight().id - val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) - } - - override fun onShowFavs(position: Int) { - val statusId = statuses[position].asRight().id - val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) - } - - override fun onLoadMore(position: Int) { - //check bounds before accessing list, - if (statuses.size >= position && position > 0) { - val fromStatus = statuses[position - 1].asRightOrNull() - val toStatus = statuses[position + 1].asRightOrNull() - val maxMinusOne = if (statuses.size > position + 1 && statuses[position + 2].isRight()) statuses[position + 1].asRight().id else null - if (fromStatus == null || toStatus == null) { - Log.e(TAG, "Failed to load more at $position, wrong placeholder position") - return - } - sendFetchTimelineRequest(fromStatus.id, toStatus.id, maxMinusOne, - FetchEnd.MIDDLE, position) - val (id1) = statuses[position].asLeft() - val newViewData: StatusViewData = StatusViewData.Placeholder(id1, true) - statuses.setPairedItem(position, newViewData) - updateAdapter() - } else { - Log.e(TAG, "error loading more") - } - } - - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - if (position < 0 || position >= statuses.size) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size - 1)) - return - } - val status = statuses.getPairedItem(position) - if (status !is StatusViewData.Concrete) { - // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't - // check for null values when adding values to it although this doesn't seem to be an issue. - Log.e(TAG, String.format( - "Expected StatusViewData.Concrete, got %s instead at position: %d of %d", - status?.javaClass?.simpleName ?: "", - position, - statuses.size - 1 - )) - return - } - val updatedStatus: StatusViewData = StatusViewData.Builder(status) - .setCollapsed(isCollapsed) - .createStatusViewData() - statuses.setPairedItem(position, updatedStatus) - updateAdapter() - } - - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = statuses.getOrNull(position)?.asRightOrNull() ?: return - super.viewMedia(attachmentIndex, status, view) - } - - override fun onViewThread(position: Int) { - super.viewThread(statuses[position].asRight()) - } - - override fun onViewTag(tag: String) { - if (kind == Kind.TAG && tags.size == 1 && tags.contains(tag)) { - // If already viewing a tag page, then ignore any request to view that tag again. - return - } - super.viewTag(tag) - } - - override fun onViewAccount(id: String) { - if ((kind == Kind.USER || kind == Kind.USER_WITH_REPLIES) && this.id == id) { - /* If already viewing an account page, then any requests to view that account page - * should be ignored. */ - return - } - super.viewAccount(id) - } - - private fun onPreferenceChanged(key: String) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - when (key) { - PrefKeys.FAB_HIDE -> { - hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) - } - PrefKeys.MEDIA_PREVIEW_ENABLED -> { - val enabled = accountManager.activeAccount!!.mediaPreviewEnabled - val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled - if (enabled != oldMediaPreviewEnabled) { - adapter.mediaPreviewEnabled = enabled - fullyRefresh() - } - } - PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) - val oldRemoveReplies = filterRemoveReplies - filterRemoveReplies = kind == Kind.HOME && !filter - if (adapter.itemCount > 1 && oldRemoveReplies != filterRemoveReplies) { - fullyRefresh() - } - } - PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) - val oldRemoveReblogs = filterRemoveReblogs - filterRemoveReblogs = kind == Kind.HOME && !filter - if (adapter.itemCount > 1 && oldRemoveReblogs != filterRemoveReblogs) { - fullyRefresh() - } - } - Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { - reloadFilters(true) - } - } - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { - //it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - } - } - } - - public override fun removeItem(position: Int) { - statuses.removeAt(position) - updateAdapter() - } - - private fun removeAllByAccountId(accountId: String) { - // using iterator to safely remove items while iterating - val iterator = statuses.iterator() - while (iterator.hasNext()) { - val status = iterator.next().asRightOrNull() - if (status != null && - (status.account.id == accountId || status.actionableStatus.account.id == accountId)) { - iterator.remove() - } - } - updateAdapter() - } - - private fun removeAllByInstance(instance: String) { - // using iterator to safely remove items while iterating - val iterator = statuses.iterator() - while (iterator.hasNext()) { - val status = iterator.next().asRightOrNull() - if (status != null && LinkHelper.getDomain(status.account.url) == instance) { - iterator.remove() - } - } - updateAdapter() - } - - private fun onLoadMore() { - if (didLoadEverythingBottom || bottomLoading) { - return - } - if (statuses.isEmpty()) { - sendInitialRequest() - return - } - bottomLoading = true - val last = statuses[statuses.size - 1] - val placeholder: Placeholder - if (last!!.isRight()) { - val placeholderId = last.asRight().id.dec() - placeholder = Placeholder(placeholderId) - statuses.add(Left(placeholder)) - } else { - placeholder = last.asLeft() - } - statuses.setPairedItem(statuses.size - 1, - StatusViewData.Placeholder(placeholder.id, true)) - updateAdapter() - - val bottomId: String? = if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { - nextId - } else { - statuses.lastOrNull { it.isRight() }?.asRight()?.id - } - - sendFetchTimelineRequest(bottomId, null, null, FetchEnd.BOTTOM, -1) - } - - private fun fullyRefresh() { - statuses.clear() - updateAdapter() - bottomLoading = true - sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1) - } - - private fun actionButtonPresent(): Boolean { - return kind != Kind.TAG && kind != Kind.FAVOURITES && kind != Kind.BOOKMARKS && - activity is ActionButtonActivity - } - - private fun getFetchCallByTimelineType(fromId: String?, uptoId: String?): Single>> { - val api = mastodonApi - return when (kind) { - Kind.HOME -> api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE) - Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, LOAD_AT_ONCE) - Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, LOAD_AT_ONCE) - Kind.TAG -> { - val firstHashtag = tags[0] - val additionalHashtags = tags.subList(1, tags.size) - api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, LOAD_AT_ONCE) - } - Kind.USER -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, true, null, null) - Kind.USER_PINNED -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, null, null, true) - Kind.USER_WITH_REPLIES -> api.accountStatuses(id!!, fromId, uptoId, LOAD_AT_ONCE, null, null, null) - Kind.FAVOURITES -> api.favourites(fromId, uptoId, LOAD_AT_ONCE) - Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, LOAD_AT_ONCE) - Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, LOAD_AT_ONCE) - else -> api.homeTimeline(fromId, uptoId, LOAD_AT_ONCE) - } - } - - private fun sendFetchTimelineRequest(maxId: String?, sinceId: String?, - sinceIdMinusOne: String?, - fetchEnd: FetchEnd, pos: Int) { - if (isAdded && (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && binding.progressBar.visibility != View.VISIBLE) && !isSwipeToRefreshEnabled) { - binding.topProgressBar.show() - } - if (kind == Kind.HOME) { - // allow getting old statuses/fallbacks for network only for for bottom loading - val mode = if (fetchEnd == FetchEnd.BOTTOM) { - TimelineRequestMode.ANY - } else { - TimelineRequestMode.NETWORK - } - timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { result: List> -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) }, - { t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) } - ) - } else { - getFetchCallByTimelineType(maxId, sinceId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( - { response: Response> -> - if (response.isSuccessful) { - val newNextId = extractNextId(response) - if (newNextId != null) { - // when we reach the bottom of the list, we won't have a new link. If - // we blindly write `null` here we will start loading from the top - // again. - nextId = newNextId - } - onFetchTimelineSuccess(liftStatusList(response.body()!!).toMutableList(), fetchEnd, pos) - } else { - onFetchTimelineFailure(Exception(response.message()), fetchEnd, pos) - } - } - ) { t: Throwable -> onFetchTimelineFailure(t, fetchEnd, pos) } - } - } - - private fun extractNextId(response: Response<*>): String? { - val linkHeader = response.headers()["Link"] ?: return null - val links = HttpHeaderLink.parse(linkHeader) - val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null - val nextLink = nextHeader.uri ?: return null - return nextLink.getQueryParameter("max_id") - } - - private fun onFetchTimelineSuccess(statuses: MutableList>, - fetchEnd: FetchEnd, pos: Int) { - - // We filled the hole (or reached the end) if the server returned less statuses than we - // we asked for. - val fullFetch = statuses.size >= LOAD_AT_ONCE - filterStatuses(statuses) - when (fetchEnd) { - FetchEnd.TOP -> { - updateStatuses(statuses, fullFetch) - } - FetchEnd.MIDDLE -> { - replacePlaceholderWithStatuses(statuses, fullFetch, pos) - } - FetchEnd.BOTTOM -> { - if (!this.statuses.isEmpty() - && !this.statuses[this.statuses.size - 1].isRight()) { - this.statuses.removeAt(this.statuses.size - 1) - updateAdapter() - } - if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { - // Removing placeholder if it's the last one from the cache - statuses.removeAt(statuses.size - 1) - } - val oldSize = this.statuses.size - if (this.statuses.size > 1) { - addItems(statuses) - } else { - updateStatuses(statuses, 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 - } - } - } - if (isAdded) { - binding.topProgressBar.hide() - updateBottomLoadingState(fetchEnd) - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - binding.swipeRefreshLayout.isEnabled = true - if (this.statuses.size == 0) { - showEmptyView() - } else { - binding.statusView.hide() - } - } - } - - private fun onFetchTimelineFailure(throwable: Throwable, fetchEnd: FetchEnd, position: Int) { - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = false - binding.topProgressBar.hide() - if (fetchEnd == FetchEnd.MIDDLE && !statuses[position].isRight()) { - var placeholder = statuses[position].asLeftOrNull() - val newViewData: StatusViewData - if (placeholder == null) { - val (id1) = statuses[position - 1].asRight() - val newId = id1.dec() - placeholder = Placeholder(newId) - } - newViewData = StatusViewData.Placeholder(placeholder.id, false) - statuses.setPairedItem(position, newViewData) - updateAdapter() - } else if (statuses.isEmpty()) { - binding.swipeRefreshLayout.isEnabled = false - binding.statusView.visibility = View.VISIBLE - if (throwable is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - binding.progressBar.visibility = View.VISIBLE - onRefresh() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - binding.progressBar.visibility = View.VISIBLE - onRefresh() - } - } - } - Log.e(TAG, "Fetch Failure: " + throwable.message) - updateBottomLoadingState(fetchEnd) - binding.progressBar.hide() - } - } - - private fun updateBottomLoadingState(fetchEnd: FetchEnd) { - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false - } - } - - private fun filterStatuses(statuses: MutableList>) { - val it = statuses.iterator() - while (it.hasNext()) { - val status = it.next().asRightOrNull() - if (status != null - && (status.inReplyToId != null && filterRemoveReplies - || status.reblog != null && filterRemoveReblogs - || shouldFilterStatus(status.actionableStatus))) { - it.remove() - } - } - } - - private fun updateStatuses(newStatuses: MutableList>, fullFetch: Boolean) { - if (newStatuses.isEmpty()) { - updateAdapter() - return - } - if (statuses.isEmpty()) { - statuses.addAll(newStatuses) - } else { - val lastOfNew = newStatuses[newStatuses.size - 1] - val index = statuses.indexOf(lastOfNew) - if (index >= 0) { - statuses.subList(0, index).clear() - } - val newIndex = newStatuses.indexOf(statuses[0]) - if (newIndex == -1) { - if (index == -1 && fullFetch) { - val placeholderId = newStatuses.last { status -> status.isRight() }.asRight().id.inc() - newStatuses.add(Left(Placeholder(placeholderId))) - } - statuses.addAll(0, newStatuses) - } else { - statuses.addAll(0, newStatuses.subList(0, newIndex)) - } - } - // Remove all consecutive placeholders - removeConsecutivePlaceholders() - updateAdapter() - } - - private fun removeConsecutivePlaceholders() { - for (i in 0 until statuses.size - 1) { - if (statuses[i].isLeft() && statuses[i + 1].isLeft()) { - statuses.removeAt(i) - } - } - } - - private fun addItems(newStatuses: List?>) { - if (newStatuses.isEmpty()) { - return - } - val last = statuses.last { status -> - status.isRight() - } - - // 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 && !newStatuses.contains(last)) { - statuses.addAll(newStatuses) - removeConsecutivePlaceholders() - updateAdapter() - } - } - - /** - * For certain requests we don't want to see placeholders, they will be removed some other way - */ - private fun clearPlaceholdersForResponse(statuses: MutableList>) { - statuses.removeAll{ status -> status.isLeft() } - } - - private fun replacePlaceholderWithStatuses(newStatuses: MutableList>, - fullFetch: Boolean, pos: Int) { - val placeholder = statuses[pos] - if (placeholder.isLeft()) { - statuses.removeAt(pos) - } - if (newStatuses.isEmpty()) { - updateAdapter() - return - } - if (fullFetch) { - newStatuses.add(placeholder) - } - statuses.addAll(pos, newStatuses) - removeConsecutivePlaceholders() - updateAdapter() - } - - private fun findStatusOrReblogPositionById(statusId: String): Int { - return statuses.indexOfFirst { either -> - val status = either.asRightOrNull() - status != null && - (statusId == status.id || - (status.reblog != null && statusId == status.reblog.id)) - } - } - - private val statusLifter: Function1> = { value -> Right(value) } - - private fun findStatusAndPosition(position: Int, status: Status): Pair? { - val statusToUpdate: StatusViewData.Concrete - val positionToUpdate: Int - val someOldViewData = statuses.getPairedItem(position) - - // Unlikely, but data could change between the request and response - if (someOldViewData is StatusViewData.Placeholder || - (someOldViewData as StatusViewData.Concrete).id != status.id) { - // try to find the status we need to update - val foundPos = statuses.indexOf(Right(status)) - if (foundPos < 0) return null // okay, it's hopeless, give up - statusToUpdate = statuses.getPairedItem(foundPos) as StatusViewData.Concrete - positionToUpdate = position - } else { - statusToUpdate = someOldViewData - positionToUpdate = position - } - return Pair(statusToUpdate, positionToUpdate) - } - - private fun handleReblogEvent(reblogEvent: ReblogEvent) { - val pos = findStatusOrReblogPositionById(reblogEvent.statusId) - if (pos < 0) return - val status = statuses[pos].asRight() - setRebloggedForStatus(pos, status, reblogEvent.reblog) - } - - private fun handleFavEvent(favEvent: FavoriteEvent) { - val pos = findStatusOrReblogPositionById(favEvent.statusId) - if (pos < 0) return - val status = statuses[pos].asRight() - setFavouriteForStatus(pos, status, favEvent.favourite) - } - - private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - val pos = findStatusOrReblogPositionById(bookmarkEvent.statusId) - if (pos < 0) return - val status = statuses[pos].asRight() - setBookmarkForStatus(pos, status, bookmarkEvent.bookmark) - } - - private fun handleStatusComposeEvent(status: Status) { - when (kind) { - Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> onRefresh() - Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) { - onRefresh() - } else { - return - } - Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return - } - } - - private fun liftStatusList(list: List): List> { - return list.map(statusLifter) - } - - private fun updateAdapter() { - differ.submitList(statuses.pairedCopy) - } - - private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - if (isAdded) { - adapter.notifyItemRangeInserted(position, count) - val context = context - // scroll up when new items at the top are loaded while being in the first position - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.itemCount != count) { - if (isSwipeToRefreshEnabled) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) - } else binding.recyclerView.scrollToPosition(0) - } - } - } - - override fun onRemoved(position: Int, count: Int) { - adapter.notifyItemRangeRemoved(position, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - adapter.notifyItemMoved(fromPosition, toPosition) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - adapter.notifyItemRangeChanged(position, count, payload) - } - } - private val differ = AsyncListDiffer(listUpdateCallback, - AsyncDifferConfig.Builder(diffCallback).build()) - - private val dataSource: TimelineAdapter.AdapterDataSource = object : TimelineAdapter.AdapterDataSource { - override fun getItemCount(): Int { - return differ.currentList.size - } - - override fun getItemAt(pos: Int): StatusViewData { - return differ.currentList[pos] - } - } - - private var talkBackWasEnabled = false - - override fun onResume() { - super.onResume() - val a11yManager = ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java) - - val wasEnabled = talkBackWasEnabled - talkBackWasEnabled = a11yManager?.isEnabled == true - Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") - if (talkBackWasEnabled && !wasEnabled) { - adapter.notifyDataSetChanged() - } - startUpdateTimestamp() - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private fun startUpdateTimestamp() { - val preferences = PreferenceManager.getDefaultSharedPreferences(activity) - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - if (!useAbsoluteTime) { - Observable.interval(1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_PAUSE)) - .subscribe { updateAdapter() } - } - } - - override fun onReselect() { - if (isAdded) { - layoutManager!!.scrollToPosition(0) - binding.recyclerView.stopScroll() - scrollListener!!.reset() - } - } - - override fun refreshContent() { - if (isAdded) { - onRefresh() - } else { - isNeedRefresh = true - } - } - - enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS - } - - private enum class FetchEnd { - TOP, BOTTOM, MIDDLE - } - - companion object { - private const val TAG = "TimelineF" // logging tag - private const val KIND_ARG = "kind" - private const val ID_ARG = "id" - private const val HASHTAGS_ARG = "hashtags" - private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" - private const val LOAD_AT_ONCE = 30 - - fun newInstance(kind: Kind, hashtagOrId: String? = null, enableSwipeToRefresh: Boolean = true): TimelineFragment { - val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, kind.name) - arguments.putString(ID_ARG, hashtagOrId) - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) - fragment.arguments = arguments - return fragment - } - - @JvmStatic - fun newHashtagInstance(hashtags: List): TimelineFragment { - val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, Kind.TAG.name) - arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - fragment.arguments = arguments - return fragment - } - - private fun filterContextMatchesKind(kind: Kind?, filterContext: List): Boolean { - // home, notifications, public, thread - return when (kind) { - Kind.HOME, Kind.LIST -> filterContext.contains(Filter.HOME) - Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains(Filter.PUBLIC) - Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains(Filter.NOTIFICATIONS) - Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains(Filter.ACCOUNT) - else -> false - } - } - - private val diffCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean { - return oldItem.viewDataId == newItem.viewDataId - } - - override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean { - return false // Items are different always. It allows to refresh timestamp on every view holder update - } - - override fun getChangePayload(oldItem: StatusViewData, newItem: StatusViewData): Any? { - return if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else // If items are different - update the whole view holder - null - } - } - } -} \ No newline at end of file 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 4f00439a..8f5ffcf1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -28,7 +28,6 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.arch.core.util.Function; -import androidx.core.util.Pair; import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DividerItemDecoration; @@ -48,6 +47,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent; import com.keylesspalace.tusky.appstore.BookmarkEvent; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.FavoriteEvent; +import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; import com.keylesspalace.tusky.appstore.StatusComposedEvent; import com.keylesspalace.tusky.appstore.StatusDeletedEvent; @@ -56,6 +56,7 @@ import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.network.FilterModel; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.CardViewMode; @@ -64,6 +65,7 @@ import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.ViewDataUtils; import com.keylesspalace.tusky.view.ConversationLineItemDecoration; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.ArrayList; @@ -73,7 +75,9 @@ import java.util.Locale; import javax.inject.Inject; +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import kotlin.collections.CollectionsKt; import static autodispose2.AutoDispose.autoDisposable; import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; @@ -86,6 +90,8 @@ public final class ViewThreadFragment extends SFragment implements public MastodonApi mastodonApi; @Inject public EventHub eventHub; + @Inject + public FilterModel filterModel; private SwipeRefreshLayout swipeRefreshLayout; private RecyclerView recyclerView; @@ -163,7 +169,7 @@ public final class ViewThreadFragment extends SFragment implements recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - reloadFilters(false); + reloadFilters(); recyclerView.setAdapter(adapter); @@ -190,6 +196,8 @@ public final class ViewThreadFragment extends SFragment implements handleReblogEvent((ReblogEvent) event); } else if (event instanceof BookmarkEvent) { handleBookmarkEvent((BookmarkEvent) event); + } else if (event instanceof PinEvent) { + handlePinEvent(((PinEvent) event)); } else if (event instanceof BlockEvent) { removeAllByAccountId(((BlockEvent) event).getAccountId()); } else if (event instanceof StatusComposedEvent) { @@ -203,13 +211,8 @@ public final class ViewThreadFragment extends SFragment implements public void onRevealPressed() { boolean allExpanded = allExpanded(); for (int i = 0; i < statuses.size(); i++) { - StatusViewData.Concrete newViewData = - new StatusViewData.Concrete.Builder(statuses.getPairedItem(i)) - .setIsExpanded(!allExpanded) - .createStatusViewData(); - statuses.setPairedItem(i, newViewData); + updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded)); } - adapter.setStatuses(statuses.getPairedCopy()); updateRevealIcon(); } @@ -239,11 +242,11 @@ public final class ViewThreadFragment extends SFragment implements public void onReblog(final boolean reblog, final int position) { final Status status = statuses.get(position); - timelineCases.reblog(statuses.get(position), reblog) + timelineCases.reblog(statuses.get(position).getId(), reblog) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> updateStatus(position, newStatus), + this::replaceStatus, (t) -> Log.d(TAG, "Failed to reblog status: " + status.getId(), t) ); @@ -253,11 +256,11 @@ public final class ViewThreadFragment extends SFragment implements public void onFavourite(final boolean favourite, final int position) { final Status status = statuses.get(position); - timelineCases.favourite(statuses.get(position), favourite) + timelineCases.favourite(statuses.get(position).getId(), favourite) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> updateStatus(position, newStatus), + this::replaceStatus, (t) -> Log.d(TAG, "Failed to favourite status: " + status.getId(), t) ); @@ -267,32 +270,29 @@ public final class ViewThreadFragment extends SFragment implements public void onBookmark(final boolean bookmark, final int position) { final Status status = statuses.get(position); - timelineCases.bookmark(statuses.get(position), bookmark) + timelineCases.bookmark(statuses.get(position).getId(), bookmark) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newStatus) -> updateStatus(position, newStatus), + this::replaceStatus, (t) -> Log.d(TAG, "Failed to bookmark status: " + status.getId(), t) ); } - private void updateStatus(int position, Status status) { + private void replaceStatus(Status status) { + updateStatus(status.getId(), (__) -> status); + } + + private void updateStatus(String statusId, Function mapper) { + int position = indexOfStatus(statusId); + if (position >= 0 && position < statuses.size()) { - - Status actionableStatus = status.getActionableStatus(); - - StatusViewData.Concrete viewData = new StatusViewData.Builder(statuses.getPairedItem(position)) - .setReblogged(actionableStatus.getReblogged()) - .setReblogsCount(actionableStatus.getReblogsCount()) - .setFavourited(actionableStatus.getFavourited()) - .setBookmarked(actionableStatus.getBookmarked()) - .setFavouritesCount(actionableStatus.getFavouritesCount()) - .createStatusViewData(); - statuses.setPairedItem(position, viewData); - - adapter.setItem(position, viewData, true); - + Status oldStatus = statuses.get(position); + Status newStatus = mapper.apply(oldStatus); + StatusViewData.Concrete oldViewData = statuses.getPairedItem(position); + statuses.set(position, newStatus); + updateViewData(position, oldViewData.copyWithStatus(newStatus)); } } @@ -304,7 +304,7 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { Status status = statuses.get(position); - super.viewMedia(attachmentIndex, status, view); + super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); } @Override @@ -314,7 +314,7 @@ public final class ViewThreadFragment extends SFragment implements // If already viewing this thread, don't reopen it. return; } - super.viewThread(status); + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); } @Override @@ -325,21 +325,22 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onExpandedChange(boolean expanded, int position) { - StatusViewData.Concrete newViewData = - new StatusViewData.Builder(statuses.getPairedItem(position)) - .setIsExpanded(expanded) - .createStatusViewData(); - statuses.setPairedItem(position, newViewData); - adapter.setItem(position, newViewData, true); + updateViewData( + position, + statuses.getPairedItem(position).copyWithExpanded(expanded) + ); updateRevealIcon(); } @Override public void onContentHiddenChange(boolean isShowing, int position) { - StatusViewData.Concrete newViewData = - new StatusViewData.Builder(statuses.getPairedItem(position)) - .setIsShowingSensitiveContent(isShowing) - .createStatusViewData(); + updateViewData( + position, + statuses.getPairedItem(position).copyWithShowingContent(isShowing) + ); + } + + private void updateViewData(int position, StatusViewData.Concrete newViewData) { statuses.setPairedItem(position, newViewData); adapter.setItem(position, newViewData, true); } @@ -365,28 +366,11 @@ public final class ViewThreadFragment extends SFragment implements @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - if (position < 0 || position >= statuses.size()) { - Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1)); - return; - } - - StatusViewData.Concrete status = statuses.getPairedItem(position); - if (status == null) { - // Statuses PairedList contains a base type of StatusViewData.Concrete and also doesn't - // check for null values when adding values to it although this doesn't seem to be an issue. - Log.e(TAG, String.format( - "Expected StatusViewData.Concrete, got null instead at position: %d of %d", - position, - statuses.size() - 1 - )); - return; - } - - StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status) - .setCollapsed(isCollapsed) - .createStatusViewData(); - statuses.setPairedItem(position, updatedStatus); - recyclerView.post(() -> adapter.setItem(position, updatedStatus, true)); + adapter.setItem( + position, + statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed), + true + ); } @Override @@ -412,28 +396,21 @@ public final class ViewThreadFragment extends SFragment implements public void onVoteInPoll(int position, @NonNull List choices) { final Status status = statuses.get(position).getActionableStatus(); - setVoteForPoll(position, status.getPoll().votedCopy(choices)); + setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices)); - timelineCases.voteInPoll(status, choices) + timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this))) .subscribe( - (newPoll) -> setVoteForPoll(position, newPoll), + (newPoll) -> setVoteForPoll(status.getId(), newPoll), (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) ); } - private void setVoteForPoll(int position, Poll newPoll) { - - StatusViewData.Concrete viewData = statuses.getPairedItem(position); - - StatusViewData.Concrete newViewData = new StatusViewData.Builder(viewData) - .setPoll(newPoll) - .createStatusViewData(); - statuses.setPairedItem(position, newViewData); - adapter.setItem(position, newViewData, true); + private void setVoteForPoll(String statusId, Poll newPoll) { + updateStatus(statusId, s -> s.copyWithPoll(newPoll)); } private void removeAllByAccountId(String accountId) { @@ -530,7 +507,7 @@ public final class ViewThreadFragment extends SFragment implements ArrayList ancestors = new ArrayList<>(); for (Status status : unfilteredAncestors) - if (!shouldFilterStatus(status)) + if (!filterModel.shouldFilterStatus(status)) ancestors.add(status); // Insert newly fetched ancestors @@ -560,7 +537,7 @@ public final class ViewThreadFragment extends SFragment implements ArrayList descendants = new ArrayList<>(); for (Status status : unfilteredDescendants) - if (!shouldFilterStatus(status)) + if (!filterModel.shouldFilterStatus(status)) descendants.add(status); // Insert newly fetched descendants @@ -581,71 +558,31 @@ public final class ViewThreadFragment extends SFragment implements } private void handleFavEvent(FavoriteEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - boolean favourite = event.getFavourite(); - posAndStatus.second.setFavourited(favourite); - - if (posAndStatus.second.getReblog() != null) { - posAndStatus.second.getReblog().setFavourited(favourite); - } - - StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); - viewDataBuilder.setFavourited(favourite); - - StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); - - statuses.setPairedItem(posAndStatus.first, newViewData); - adapter.setItem(posAndStatus.first, newViewData, true); + updateStatus(event.getStatusId(), (s) -> { + s.setFavourited(event.getFavourite()); + return s; + }); } private void handleReblogEvent(ReblogEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - boolean reblog = event.getReblog(); - posAndStatus.second.setReblogged(reblog); - - if (posAndStatus.second.getReblog() != null) { - posAndStatus.second.getReblog().setReblogged(reblog); - } - - StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); - viewDataBuilder.setReblogged(reblog); - - StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); - - statuses.setPairedItem(posAndStatus.first, newViewData); - adapter.setItem(posAndStatus.first, newViewData, true); + updateStatus(event.getStatusId(), (s) -> { + s.setReblogged(event.getReblog()); + return s; + }); } private void handleBookmarkEvent(BookmarkEvent event) { - Pair posAndStatus = findStatusAndPos(event.getStatusId()); - if (posAndStatus == null) return; - - boolean bookmark = event.getBookmark(); - posAndStatus.second.setBookmarked(bookmark); - - if (posAndStatus.second.getReblog() != null) { - posAndStatus.second.getReblog().setBookmarked(bookmark); - } - - StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first); - - StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata)); - viewDataBuilder.setBookmarked(bookmark); - - StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData(); - - statuses.setPairedItem(posAndStatus.first, newViewData); - adapter.setItem(posAndStatus.first, newViewData, true); + updateStatus(event.getStatusId(), (s) -> { + s.setBookmarked(event.getBookmark()); + return s; + }); } + private void handlePinEvent(PinEvent event) { + updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned())); + } + + private void handleStatusComposedEvent(StatusComposedEvent event) { Status eventStatus = event.getStatus(); if (eventStatus.getInReplyToId() == null) return; @@ -671,23 +608,16 @@ public final class ViewThreadFragment extends SFragment implements } 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); + int index = this.indexOfStatus(event.getStatusId()); + if (index != -1) { + statuses.remove(index); + adapter.removeItem(index); + } } - @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 int indexOfStatus(String statusId) { + return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId)); } private void updateRevealIcon() { @@ -710,13 +640,25 @@ public final class ViewThreadFragment extends SFragment implements ViewThreadActivity.REVEAL_BUTTON_REVEAL); } - @Override - protected boolean filterIsRelevant(@NonNull Filter filter) { - return filter.getContext().contains(Filter.THREAD); + private void reloadFilters() { + mastodonApi.getFilters() + .to(autoDisposable(AndroidLifecycleScopeProvider.from(this))) + .subscribe( + (filters) -> { + List relevantFilters = CollectionsKt.filter( + filters, + (f) -> f.getContext().contains(Filter.THREAD) + ); + filterModel.initWithFilters(relevantFilters); + + recyclerView.post(this::applyFilters); + }, + (t) -> Log.e(TAG, "Failed to load filters", t) + ); } - @Override - protected void refreshAfterApplyingFilters() { - onRefresh(); + private void applyFilters() { + CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus); + adapter.setStatuses(this.statuses.getPairedCopy()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index ec37680c..116e582c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -38,7 +38,7 @@ public interface StatusActionListener extends LinkListener { void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); + void onLoadMore(int position); /** * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt new file mode 100644 index 00000000..3be20c1a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -0,0 +1,56 @@ +package com.keylesspalace.tusky.network + +import android.text.TextUtils +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Status +import java.util.regex.Pattern +import javax.inject.Inject + +/** + * One-stop for status filtering logic using Mastodon's filters. + * + * 1. You init with [initWithFilters], this compiles regex pattern. + * 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. + */ +class FilterModel @Inject constructor() { + private var pattern: Pattern? = null + + fun initWithFilters(filters: List) { + this.pattern = makeFilter(filters) + } + + fun shouldFilterStatus(status: Status): Boolean { + // Patterns are expensive and thread-safe, matchers are neither. + val matcher = pattern?.matcher("") ?: return false + + if (status.poll != null) { + val pollMatches = status.poll.options.any { matcher.reset(it.title).find() } + if (pollMatches) return true + } + + val spoilerText = status.actionableStatus.spoilerText + return (matcher.reset(status.actionableStatus.content).find() || + spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) + } + + private fun filterToRegexToken(filter: Filter): String? { + val phrase = filter.phrase + val quotedPhrase = Pattern.quote(phrase) + return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { + String.format("(^|\\W)%s($|\\W)", quotedPhrase) + } else { + quotedPhrase + } + } + + private fun makeFilter(filters: List): Pattern? { + if (filters.isEmpty()) return null + val tokens = filters.map { filterToRegexToken(it) } + + return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE); + } + + companion object { + private val ALPHANUMERIC = Pattern.compile("^\\w+$") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index ae6fc3c2..dfa681f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -49,7 +49,7 @@ interface MastodonApi { fun getInstance(): Single @GET("api/v1/filters") - fun getFilters(): Call> + fun getFilters(): Single> @GET("api/v1/timelines/home") fun homeTimeline( 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 f124faed..96fff6f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt @@ -30,20 +30,20 @@ import java.lang.IllegalStateException */ interface TimelineCases { - fun reblog(status: Status, reblog: Boolean): Single - fun favourite(status: Status, favourite: Boolean): Single - fun bookmark(status: Status, bookmark: Boolean): Single - fun mute(id: String, notifications: Boolean, duration: Int?) - fun block(id: String) - fun delete(id: String): Single - fun pin(status: Status, pin: Boolean) - fun voteInPoll(status: Status, choices: List): Single - fun muteConversation(status: Status, mute: Boolean): Single + fun reblog(statusId: String, reblog: Boolean): Single + fun favourite(statusId: String, favourite: Boolean): Single + fun bookmark(statusId: String, bookmark: Boolean): Single + fun mute(statusId: String, notifications: Boolean, duration: Int?) + fun block(statusId: String) + fun delete(statusId: String): Single + fun pin(statusId: String, pin: Boolean): Single + fun voteInPoll(statusId: String, pollId: String, choices: List): Single + fun muteConversation(statusId: String, mute: Boolean): Single } class TimelineCasesImpl( - private val mastodonApi: MastodonApi, - private val eventHub: EventHub + private val mastodonApi: MastodonApi, + private val eventHub: EventHub ) : TimelineCases { /** @@ -52,103 +52,92 @@ class TimelineCasesImpl( */ private val cancelDisposable = CompositeDisposable() - override fun reblog(status: Status, reblog: Boolean): Single { - val id = status.actionableId - + override fun reblog(statusId: String, reblog: Boolean): Single { val call = if (reblog) { - mastodonApi.reblogStatus(id) + mastodonApi.reblogStatus(statusId) } else { - mastodonApi.unreblogStatus(id) + mastodonApi.unreblogStatus(statusId) } return call.doAfterSuccess { - eventHub.dispatch(ReblogEvent(status.id, reblog)) + eventHub.dispatch(ReblogEvent(statusId, reblog)) } } - override fun favourite(status: Status, favourite: Boolean): Single { - val id = status.actionableId - + override fun favourite(statusId: String, favourite: Boolean): Single { val call = if (favourite) { - mastodonApi.favouriteStatus(id) + mastodonApi.favouriteStatus(statusId) } else { - mastodonApi.unfavouriteStatus(id) + mastodonApi.unfavouriteStatus(statusId) } return call.doAfterSuccess { - eventHub.dispatch(FavoriteEvent(status.id, favourite)) + eventHub.dispatch(FavoriteEvent(statusId, favourite)) } } - override fun bookmark(status: Status, bookmark: Boolean): Single { - val id = status.actionableId - + override fun bookmark(statusId: String, bookmark: Boolean): Single { val call = if (bookmark) { - mastodonApi.bookmarkStatus(id) + mastodonApi.bookmarkStatus(statusId) } else { - mastodonApi.unbookmarkStatus(id) + mastodonApi.unbookmarkStatus(statusId) } return call.doAfterSuccess { - eventHub.dispatch(BookmarkEvent(status.id, bookmark)) + eventHub.dispatch(BookmarkEvent(statusId, bookmark)) } } - override fun muteConversation(status: Status, mute: Boolean): Single { - val id = status.actionableId - + override fun muteConversation(statusId: String, mute: Boolean): Single { val call = if (mute) { - mastodonApi.muteConversation(id) + mastodonApi.muteConversation(statusId) } else { - mastodonApi.unmuteConversation(id) + mastodonApi.unmuteConversation(statusId) } return call.doAfterSuccess { - eventHub.dispatch(MuteConversationEvent(status.id, mute)) + eventHub.dispatch(MuteConversationEvent(statusId, mute)) } } - override fun mute(id: String, notifications: Boolean, duration: Int?) { - mastodonApi.muteAccount(id, notifications, duration) - .subscribe({ - eventHub.dispatch(MuteEvent(id)) - }, { t -> - Log.w("Failed to mute account", t) - }) - .addTo(cancelDisposable) + override fun mute(statusId: String, notifications: Boolean, duration: Int?) { + mastodonApi.muteAccount(statusId, notifications, duration) + .subscribe({ + eventHub.dispatch(MuteEvent(statusId)) + }, { t -> + Log.w("Failed to mute account", t) + }) + .addTo(cancelDisposable) } - override fun block(id: String) { - mastodonApi.blockAccount(id) - .subscribe({ - eventHub.dispatch(BlockEvent(id)) - }, { t -> - Log.w("Failed to block account", t) - }) - .addTo(cancelDisposable) + override fun block(statusId: String) { + mastodonApi.blockAccount(statusId) + .subscribe({ + eventHub.dispatch(BlockEvent(statusId)) + }, { t -> + Log.w("Failed to block account", t) + }) + .addTo(cancelDisposable) } - override fun delete(id: String): Single { - return mastodonApi.deleteStatus(id) - .doAfterSuccess { - eventHub.dispatch(StatusDeletedEvent(id)) - } + override fun delete(statusId: String): Single { + return mastodonApi.deleteStatus(statusId) + .doAfterSuccess { + eventHub.dispatch(StatusDeletedEvent(statusId)) + } } - override fun pin(status: Status, pin: Boolean) { + override fun pin(statusId: String, pin: Boolean): Single { // Replace with extension method if we use RxKotlin - (if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id)) - .subscribe({ updatedStatus -> - status.pinned = updatedStatus.pinned - }, {}) - .addTo(this.cancelDisposable) + return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId)) + .doAfterSuccess { + eventHub.dispatch(PinEvent(statusId, pin)) + } } - override fun voteInPoll(status: Status, choices: List): Single { - val pollId = status.actionableStatus.poll?.id - - if(pollId == null || choices.isEmpty()) { + override fun voteInPoll(statusId: String, pollId: String, choices: List): Single { + if (choices.isEmpty()) { return Single.error(IllegalStateException()) } return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess { - eventHub.dispatch(PollVoteEvent(status.id, it)) + eventHub.dispatch(PollVoteEvent(statusId, it)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt index f8c026e0..e2e13c66 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt @@ -18,7 +18,8 @@ package com.keylesspalace.tusky.pager import androidx.fragment.app.* import com.keylesspalace.tusky.fragment.AccountMediaFragment -import com.keylesspalace.tusky.fragment.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.timeline.TimelineViewModel import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.util.CustomFragmentStateAdapter @@ -32,9 +33,9 @@ class AccountPagerAdapter( override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false) - 1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false) - 2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false) + 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) + 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) + 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) 3 -> AccountMediaFragment.newInstance(accountId, false) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt deleted file mode 100644 index d6f25a16..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ /dev/null @@ -1,392 +0,0 @@ -package com.keylesspalace.tusky.repository - -import android.text.SpannedString -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.keylesspalace.tusky.db.* -import com.keylesspalace.tusky.entity.* -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK -import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.inc -import com.keylesspalace.tusky.util.trimTrailingWhitespace -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers -import java.io.IOException -import java.util.* -import java.util.concurrent.TimeUnit -import kotlin.collections.ArrayList - -data class Placeholder(val id: String) - -typealias TimelineStatus = Either - -enum class TimelineRequestMode { - DISK, NETWORK, ANY -} - -interface TimelineRepository { - fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, - requestMode: TimelineRequestMode): Single> - - companion object { - val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) - } -} - -class TimelineRepositoryImpl( - private val timelineDao: TimelineDao, - private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, - private val gson: Gson -) : TimelineRepository { - - init { - this.cleanup() - } - - override fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, - limit: Int, requestMode: TimelineRequestMode - ): Single> { - val acc = accountManager.activeAccount ?: throw IllegalStateException() - val accountId = acc.id - - return if (requestMode == DISK) { - this.getStatusesFromDb(accountId, maxId, sinceId, limit) - } else { - getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) - } - } - - private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, - sinceIdMinusOne: String?, limit: Int, - accountId: Long, requestMode: TimelineRequestMode - ): Single> { - return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) - .map { response -> - this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) - } - .flatMap { statuses -> - this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) - } - .onErrorResumeNext { error -> - if (error is IOException && requestMode != NETWORK) { - this.getStatusesFromDb(accountId, maxId, sinceId, limit) - } else { - Single.error(error) - } - } - } - - private fun addFromDbIfNeeded(accountId: Long, statuses: List>, - maxId: String?, sinceId: String?, limit: Int, - requestMode: TimelineRequestMode - ): Single> { - return if (requestMode != NETWORK && statuses.size < 2) { - val newMaxID = if (statuses.isEmpty()) { - maxId - } else { - statuses.last { it.isRight() }.asRight().id - } - this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) - .map { fromDb -> - // If it's just placeholders and less than limit (so we exhausted both - // db and server at this point) - if (fromDb.size < limit && fromDb.all { !it.isRight() }) { - statuses - } else { - statuses + fromDb - } - } - } else { - Single.just(statuses) - } - } - - private fun getStatusesFromDb(accountId: Long, maxId: String?, sinceId: String?, - limit: Int): Single> { - return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) - .subscribeOn(Schedulers.io()) - .map { statuses -> - statuses.map { it.toStatus() } - } - } - - private fun saveStatusesToDb(accountId: Long, statuses: List, - maxId: String?, sinceId: String? - ): List> { - var placeholderToInsert: Placeholder? = null - - // Look for overlap - val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { - val indexOfSince = statuses.indexOfLast { it.id == sinceId } - if (indexOfSince == -1) { - // We didn't find the status which must be there. Add a placeholder - placeholderToInsert = Placeholder(sinceId.inc()) - statuses.mapTo(mutableListOf(), Status::lift) - .apply { - add(Either.Left(placeholderToInsert)) - } - } else { - // There was an overlap. Remove all overlapped statuses. No need for a placeholder. - statuses.mapTo(mutableListOf(), Status::lift) - .apply { - subList(indexOfSince, size).clear() - } - } - } else { - // Just a normal case. - statuses.map(Status::lift) - } - - Single.fromCallable { - - if(statuses.isNotEmpty()) { - timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) - } - - for (status in statuses) { - timelineDao.insertInTransaction( - status.toEntity(accountId, gson), - status.account.toEntity(accountId, gson), - status.reblog?.account?.toEntity(accountId, gson) - ) - } - - placeholderToInsert?.let { - timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) - } - - // If we're loading in the bottom insert placeholder after every load - // (for requests on next launches) but not return it. - if (sinceId == null && statuses.isNotEmpty()) { - timelineDao.insertStatusIfNotThere( - Placeholder(statuses.last().id.dec()).toEntity(accountId)) - } - - // There may be placeholders which we thought could be from our TL but they are not - if (statuses.size > 2) { - timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, - statuses.last().id) - } else if (placeholderToInsert == null && maxId != null && sinceId != null) { - timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) - } - } - .subscribeOn(Schedulers.io()) - .subscribe() - - return resultStatuses - } - - private fun cleanup() { - Schedulers.io().scheduleDirect { - val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL - timelineDao.cleanup(olderThan) - } - } - - private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { - if (this.status.authorServerId == null) { - return Either.Left(Placeholder(this.status.serverId)) - } - - val attachments: ArrayList = gson.fromJson(status.attachments, - object : TypeToken>() {}.type) ?: ArrayList() - val mentions: Array = gson.fromJson(status.mentions, - Array::class.java) ?: arrayOf() - val application = gson.fromJson(status.application, Status.Application::class.java) - val emojis: List = gson.fromJson(status.emojis, - object : TypeToken>() {}.type) ?: listOf() - val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) - - val reblog = status.reblogServerId?.let { id -> - Status( - id = id, - url = status.url, - account = account.toAccount(gson), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), - createdAt = Date(status.createdAt), - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText!!, - visibility = status.visibility!!, - attachments = attachments, - mentions = mentions, - application = application, - pinned = false, - muted = status.muted, - poll = poll, - card = null - ) - } - val status = if (reblog != null) { - Status( - id = status.serverId, - url = null, // no url for reblogs - account = this.reblogAccount!!.toAccount(gson), - inReplyToId = null, - inReplyToAccountId = null, - reblog = reblog, - content = SpannedString(""), - createdAt = Date(status.createdAt), // lie but whatever? - emojis = listOf(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = status.visibility!!, - attachments = ArrayList(), - mentions = arrayOf(), - application = null, - pinned = false, - muted = status.muted, - poll = null, - card = null - ) - } else { - Status( - id = status.serverId, - url = status.url, - account = account.toAccount(gson), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() ?: SpannedString(""), - createdAt = Date(status.createdAt), - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText!!, - visibility = status.visibility!!, - attachments = attachments, - mentions = mentions, - application = application, - pinned = false, - muted = status.muted, - poll = poll, - card = null - ) - } - return Either.Right(status) - } -} - -private val emojisListTypeToken = object : TypeToken>() {} - -fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { - return TimelineAccountEntity( - serverId = id, - timelineUserId = accountId, - localUsername = localUsername, - username = username, - displayName = name, - url = url, - avatar = avatar, - emojis = gson.toJson(emojis), - bot = bot - ) -} - -fun TimelineAccountEntity.toAccount(gson: Gson): Account { - return Account( - id = serverId, - localUsername = localUsername, - username = username, - displayName = displayName, - note = SpannedString(""), - url = url, - avatar = avatar, - header = "", - locked = false, - followingCount = 0, - followersCount = 0, - statusesCount = 0, - source = null, - bot = bot, - emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), - fields = null, - moved = null - ) -} - - -fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = null, - visibility = null, - attachments = null, - mentions = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false - ) -} - -fun Status.toEntity(timelineUserId: Long, - gson: Gson): TimelineStatusEntity { - val actionable = actionableStatus - return TimelineStatusEntity( - serverId = this.id, - url = actionable.url!!, - timelineUserId = timelineUserId, - authorServerId = actionable.account.id, - inReplyToId = actionable.inReplyToId, - inReplyToAccountId = actionable.inReplyToAccountId, - content = actionable.content.toHtml(), - createdAt = actionable.createdAt.time, - emojis = actionable.emojis.let(gson::toJson), - reblogsCount = actionable.reblogsCount, - favouritesCount = actionable.favouritesCount, - reblogged = actionable.reblogged, - favourited = actionable.favourited, - bookmarked = actionable.bookmarked, - sensitive = actionable.sensitive, - spoilerText = actionable.spoilerText, - visibility = actionable.visibility, - attachments = actionable.attachments.let(gson::toJson), - mentions = actionable.mentions.let(gson::toJson), - application = actionable.application.let(gson::toJson), - reblogServerId = reblog?.id, - reblogAccountId = reblog?.let { this.account.id }, - poll = actionable.poll.let(gson::toJson), - muted = actionable.muted - ) -} - -fun Status.lift(): Either = Either.Right(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index ec0c8a3e..b0a47574 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -40,6 +40,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener; import java.net.URI; import java.net.URISyntaxException; +import java.util.List; public class LinkHelper { public static String getDomain(String urlString) { @@ -69,7 +70,7 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableText(TextView view, CharSequence content, - @Nullable Status.Mention[] mentions, final LinkListener listener) { + @Nullable List mentions, final LinkListener listener) { SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content); URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class); for (URLSpan span : urlSpans) { @@ -85,7 +86,7 @@ public class LinkHelper { @Override public void onClick(@NonNull View widget) { listener.onViewTag(tag); } }; - } else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) { + } else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) { String accountUsername = text.subSequence(1, text.length()).toString(); /* There may be multiple matches for users on different instances with the same * username. If a match has the same domain we know it's for sure the same, but if @@ -141,8 +142,8 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableMentions( - TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) { - if (mentions == null || mentions.length == 0) { + TextView view, @Nullable List mentions, final LinkListener listener) { + if (mentions == null || mentions.size() == 0) { view.setText(null); return; } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 859162da..93e0c67b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -27,9 +27,9 @@ fun interface StatusProvider { } class ListStatusAccessibilityDelegate( - private val recyclerView: RecyclerView, - private val statusActionListener: StatusActionListener, - private val statusProvider: StatusProvider + private val recyclerView: RecyclerView, + private val statusActionListener: StatusActionListener, + private val statusProvider: StatusProvider ) : RecyclerViewAccessibilityDelegate(recyclerView) { private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager @@ -39,8 +39,10 @@ class ListStatusAccessibilityDelegate( private val context: Context get() = recyclerView.context private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) { - override fun onInitializeAccessibilityNodeInfo(host: View, - info: AccessibilityNodeInfoCompat) { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat + ) { super.onInitializeAccessibilityNodeInfo(host, info) val pos = recyclerView.getChildAdapterPosition(host) @@ -52,44 +54,51 @@ class ListStatusAccessibilityDelegate( info.addAction(replyAction) - if (status.rebloggingEnabled) { - info.addAction(if (status.isReblogged) unreblogAction else reblogAction) + val actionable = status.actionable + if (actionable.rebloggingAllowed()) { + info.addAction(if (actionable.reblogged) unreblogAction else reblogAction) } - info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction) - info.addAction(if (status.isBookmarked) unbookmarkAction else bookmarkAction) + info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction) + info.addAction(if (actionable.bookmarked) unbookmarkAction else bookmarkAction) val mediaActions = intArrayOf( - R.id.action_open_media_1, - R.id.action_open_media_2, - R.id.action_open_media_3, - R.id.action_open_media_4) - val attachmentCount = min(status.attachments.size, MAX_MEDIA_ATTACHMENTS) + R.id.action_open_media_1, + R.id.action_open_media_2, + R.id.action_open_media_3, + R.id.action_open_media_4 + ) + val attachmentCount = min(actionable.attachments.size, MAX_MEDIA_ATTACHMENTS) for (i in 0 until attachmentCount) { - info.addAction(AccessibilityActionCompat( + info.addAction( + AccessibilityActionCompat( mediaActions[i], - context.getString(R.string.action_open_media_n, i + 1))) + context.getString(R.string.action_open_media_n, i + 1) + ) + ) } info.addAction(openProfileAction) if (getLinks(status).any()) info.addAction(linksAction) - val mentions = status.mentions - if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction) + val mentions = actionable.mentions + if (mentions.isNotEmpty()) info.addAction(mentionsAction) if (getHashtags(status).any()) info.addAction(hashtagsAction) - if (!status.rebloggedByUsername.isNullOrEmpty()) { + if (!status.status.reblog?.account?.username.isNullOrEmpty()) { info.addAction(openRebloggerAction) } - if (status.reblogsCount > 0) info.addAction(openRebloggedByAction) - if (status.favouritesCount > 0) info.addAction(openFavsAction) + if (actionable.reblogsCount > 0) info.addAction(openRebloggedByAction) + if (actionable.favouritesCount > 0) info.addAction(openFavsAction) info.addAction(moreAction) } } - override fun performAccessibilityAction(host: View, action: Int, - args: Bundle?): Boolean { + override fun performAccessibilityAction( + host: View, action: Int, + args: Bundle? + ): Boolean { val pos = recyclerView.getChildAdapterPosition(host) when (action) { R.id.action_reply -> { @@ -105,7 +114,8 @@ class ListStatusAccessibilityDelegate( R.id.action_open_profile -> { interrupt() statusActionListener.onViewAccount( - (statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId) + (statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id + ) } R.id.action_open_media_1 -> { interrupt() @@ -166,43 +176,51 @@ class ListStatusAccessibilityDelegate( val links = getLinks(status).toList() val textLinks = links.map { item -> item.link } AlertDialog.Builder(host.context) - .setTitle(R.string.title_links_dialog) - .setAdapter(ArrayAdapter( - host.context, - android.R.layout.simple_list_item_1, - textLinks) - ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } - .show() - .let { forceFocus(it.listView) } + .setTitle(R.string.title_links_dialog) + .setAdapter( + ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, + textLinks + ) + ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } + .show() + .let { forceFocus(it.listView) } } private fun showMentionsDialog(host: View) { val status = getStatus(host) as? StatusViewData.Concrete ?: return - val mentions = status.mentions ?: return + val mentions = status.actionable.mentions val stringMentions = mentions.map { it.username } AlertDialog.Builder(host.context) - .setTitle(R.string.title_mentions_dialog) - .setAdapter(ArrayAdapter(host.context, - android.R.layout.simple_list_item_1, stringMentions) - ) { _, which -> - statusActionListener.onViewAccount(mentions[which].id) - } - .show() - .let { forceFocus(it.listView) } + .setTitle(R.string.title_mentions_dialog) + .setAdapter( + ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, stringMentions + ) + ) { _, which -> + statusActionListener.onViewAccount(mentions[which].id) + } + .show() + .let { forceFocus(it.listView) } } private fun showHashtagsDialog(host: View) { val status = getStatus(host) as? StatusViewData.Concrete ?: return val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() AlertDialog.Builder(host.context) - .setTitle(R.string.title_hashtags_dialog) - .setAdapter(ArrayAdapter(host.context, - android.R.layout.simple_list_item_1, tags) - ) { _, which -> - statusActionListener.onViewTag(tags[which].toString()) - } - .show() - .let { forceFocus(it.listView) } + .setTitle(R.string.title_hashtags_dialog) + .setAdapter( + ArrayAdapter( + host.context, + android.R.layout.simple_list_item_1, tags + ) + ) { _, which -> + statusActionListener.onViewTag(tags[which].toString()) + } + .show() + .let { forceFocus(it.listView) } } private fun getStatus(childView: View): StatusViewData { @@ -215,14 +233,15 @@ class ListStatusAccessibilityDelegate( val content = status.content return if (content is Spannable) { content.getSpans(0, content.length, URLSpan::class.java) - .asSequence() - .map { span -> - val text = content.subSequence( - content.getSpanStart(span), - content.getSpanEnd(span)) - if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url) - } - .filterNotNull() + .asSequence() + .map { span -> + val text = content.subSequence( + content.getSpanStart(span), + content.getSpanEnd(span) + ) + if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url) + } + .filterNotNull() } else { emptySequence() } @@ -231,11 +250,11 @@ class ListStatusAccessibilityDelegate( private fun getHashtags(status: StatusViewData.Concrete): Sequence { val content = status.content return content.getSpans(0, content.length, Object::class.java) - .asSequence() - .map { span -> - content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)) - } - .filter(this::isHashtag) + .asSequence() + .map { span -> + content.subSequence(content.getSpanStart(span), content.getSpanEnd(span)) + } + .filter(this::isHashtag) } private fun forceFocus(host: View) { @@ -253,72 +272,88 @@ class ListStatusAccessibilityDelegate( private fun isHashtag(text: CharSequence) = text.startsWith("#") private val collapseCwAction = AccessibilityActionCompat( - R.id.action_collapse_cw, - context.getString(R.string.status_content_warning_show_less)) + R.id.action_collapse_cw, + context.getString(R.string.status_content_warning_show_less) + ) private val expandCwAction = AccessibilityActionCompat( - R.id.action_expand_cw, - context.getString(R.string.status_content_warning_show_more)) + R.id.action_expand_cw, + context.getString(R.string.status_content_warning_show_more) + ) private val replyAction = AccessibilityActionCompat( - R.id.action_reply, - context.getString(R.string.action_reply)) + R.id.action_reply, + context.getString(R.string.action_reply) + ) private val unreblogAction = AccessibilityActionCompat( - R.id.action_unreblog, - context.getString(R.string.action_unreblog)) + R.id.action_unreblog, + context.getString(R.string.action_unreblog) + ) private val reblogAction = AccessibilityActionCompat( - R.id.action_reblog, - context.getString(R.string.action_reblog)) + R.id.action_reblog, + context.getString(R.string.action_reblog) + ) private val unfavouriteAction = AccessibilityActionCompat( - R.id.action_unfavourite, - context.getString(R.string.action_unfavourite)) + R.id.action_unfavourite, + context.getString(R.string.action_unfavourite) + ) private val favouriteAction = AccessibilityActionCompat( - R.id.action_favourite, - context.getString(R.string.action_favourite)) + R.id.action_favourite, + context.getString(R.string.action_favourite) + ) private val bookmarkAction = AccessibilityActionCompat( - R.id.action_bookmark, - context.getString(R.string.action_bookmark)) + R.id.action_bookmark, + context.getString(R.string.action_bookmark) + ) private val unbookmarkAction = AccessibilityActionCompat( - R.id.action_unbookmark, - context.getString(R.string.action_bookmark)) + R.id.action_unbookmark, + context.getString(R.string.action_bookmark) + ) private val openProfileAction = AccessibilityActionCompat( - R.id.action_open_profile, - context.getString(R.string.action_view_profile)) + R.id.action_open_profile, + context.getString(R.string.action_view_profile) + ) private val linksAction = AccessibilityActionCompat( - R.id.action_links, - context.getString(R.string.action_links)) + R.id.action_links, + context.getString(R.string.action_links) + ) private val mentionsAction = AccessibilityActionCompat( - R.id.action_mentions, - context.getString(R.string.action_mentions)) + R.id.action_mentions, + context.getString(R.string.action_mentions) + ) private val hashtagsAction = AccessibilityActionCompat( - R.id.action_hashtags, - context.getString(R.string.action_hashtags)) + R.id.action_hashtags, + context.getString(R.string.action_hashtags) + ) private val openRebloggerAction = AccessibilityActionCompat( - R.id.action_open_reblogger, - context.getString(R.string.action_open_reblogger)) + R.id.action_open_reblogger, + context.getString(R.string.action_open_reblogger) + ) private val openRebloggedByAction = AccessibilityActionCompat( - R.id.action_open_reblogged_by, - context.getString(R.string.action_open_reblogged_by)) + R.id.action_open_reblogged_by, + context.getString(R.string.action_open_reblogged_by) + ) private val openFavsAction = AccessibilityActionCompat( - R.id.action_open_faved_by, - context.getString(R.string.action_open_faved_by)) + R.id.action_open_faved_by, + context.getString(R.string.action_open_faved_by) + ) private val moreAction = AccessibilityActionCompat( - R.id.action_more, - context.getString(R.string.action_more) + R.id.action_more, + context.getString(R.string.action_more) ) private data class LinkSpanInfo(val text: String, val link: String) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt index 8a5223ce..28ef0c63 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -52,4 +52,8 @@ inline fun List.replacedFirstWhich(replacement: T, predicate: (T) -> Bool newList[index] = replacement } return newList +} + +inline fun Iterable<*>.firstIsInstanceOrNull(): R? { + return firstOrNull { it is R }?.let { it as R } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 83eaeafa..23423b59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -73,6 +73,15 @@ fun String.isLessThan(other: String): Boolean { } } +fun String.idCompareTo(other: String): Int { + return when { + this === other -> 0 + this.length < other.length -> -1 + this.length > other.length -> 1 + else -> this.compareTo(other) + } +} + fun Spanned.trimTrailingWhitespace(): Spanned { var i = length do { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java deleted file mode 100644 index 2e8e67ef..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ /dev/null @@ -1,86 +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.util; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -/** - * Created by charlag on 12/07/2017. - */ - -public final class ViewDataUtils { - @Nullable - public static StatusViewData.Concrete statusToViewData(@Nullable Status status, - boolean alwaysShowSensitiveMedia, - boolean alwaysOpenSpoiler) { - if (status == null) return null; - Status visibleStatus = status.getReblog() == null ? status : status.getReblog(); - return new StatusViewData.Builder().setId(status.getId()) - .setAttachments(visibleStatus.getAttachments()) - .setAvatar(visibleStatus.getAccount().getAvatar()) - .setContent(visibleStatus.getContent()) - .setCreatedAt(visibleStatus.getCreatedAt()) - .setReblogsCount(visibleStatus.getReblogsCount()) - .setFavouritesCount(visibleStatus.getFavouritesCount()) - .setInReplyToId(visibleStatus.getInReplyToId()) - .setFavourited(visibleStatus.getFavourited()) - .setBookmarked(visibleStatus.getBookmarked()) - .setReblogged(visibleStatus.getReblogged()) - .setIsExpanded(alwaysOpenSpoiler) - .setIsShowingSensitiveContent(false) - .setMentions(visibleStatus.getMentions()) - .setNickname(visibleStatus.getAccount().getUsername()) - .setRebloggedAvatar(status.getReblog() == null ? null : status.getAccount().getAvatar()) - .setSensitive(visibleStatus.getSensitive()) - .setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive()) - .setSpoilerText(visibleStatus.getSpoilerText()) - .setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getName()) - .setUserFullName(visibleStatus.getAccount().getName()) - .setVisibility(visibleStatus.getVisibility()) - .setSenderId(visibleStatus.getAccount().getId()) - .setRebloggingEnabled(visibleStatus.rebloggingAllowed()) - .setApplication(visibleStatus.getApplication()) - .setStatusEmojis(visibleStatus.getEmojis()) - .setAccountEmojis(visibleStatus.getAccount().getEmojis()) - .setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis()) - .setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent())) - .setCollapsed(true) - .setPoll(visibleStatus.getPoll()) - .setCard(visibleStatus.getCard()) - .setIsBot(visibleStatus.getAccount().getBot()) - .createStatusViewData(); - } - - public static NotificationViewData.Concrete notificationToViewData(Notification notification, - boolean alwaysShowSensitiveData, - boolean alwaysOpenSpoiler) { - return new NotificationViewData.Concrete( - notification.getType(), - notification.getId(), - notification.getAccount(), - statusToViewData( - notification.getStatus(), - alwaysShowSensitiveData, - alwaysOpenSpoiler - ) - ); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt new file mode 100644 index 00000000..21e522f0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -0,0 +1,53 @@ +@file:JvmName("ViewDataUtils") + +/* 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.util + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.toViewData +import java.util.* + +@JvmName("statusToViewData") +fun Status.toViewData( + alwaysShowSensitiveMedia: Boolean, + alwaysOpenSpoiler: Boolean +): StatusViewData.Concrete { + val visibleStatus = this.reblog ?: this + + return StatusViewData.Concrete( + status = this, + isShowingContent = alwaysShowSensitiveMedia || !visibleStatus.sensitive, + isCollapsible = shouldTrimStatus(visibleStatus.content), + isCollapsed = false, + isExpanded = alwaysOpenSpoiler, + ) +} + +@JvmName("notificationToViewData") +fun Notification.toViewData( + alwaysShowSensitiveData: Boolean, + alwaysOpenSpoiler: Boolean +): NotificationViewData.Concrete { + return NotificationViewData.Concrete( + this.type, + this.id, + this.account, + this.status?.toViewData(alwaysShowSensitiveData, alwaysOpenSpoiler) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt index 4011d69d..7be8d06e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt @@ -47,13 +47,13 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie val dividerBottom: Int if (current != null) { val above = adapter.getItem(position - 1) - dividerTop = if (above != null && above.id == current.inReplyToId) { + dividerTop = if (above != null && above.id == current.status.inReplyToId) { child.top } else { child.top + avatarMargin } val below = adapter.getItem(position + 1) - dividerBottom = if (below != null && current.id == below.inReplyToId && + dividerBottom = if (below != null && current.id == below.status.inReplyToId && adapter.detailedStatusPosition != position) { child.bottom } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index a7b2bffc..f2e42e40 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -19,12 +19,5 @@ data class AttachmentViewData( AttachmentViewData(it, actionable.id, actionable.url!!) } } - - fun list(attachments: List): List { - return attachments.map { - AttachmentViewData(it, it.id, it.url) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index 3256c159..409b858d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -86,9 +86,7 @@ public abstract class NotificationViewData { return type == concrete.type && Objects.equals(id, concrete.id) && account.getId().equals(concrete.account.getId()) && - (statusViewData == concrete.statusViewData || - statusViewData != null && - statusViewData.deepEquals(concrete.statusViewData)); + (Objects.equals(statusViewData, concrete.statusViewData)); } @Override @@ -96,6 +94,10 @@ public abstract class NotificationViewData { return Objects.hash(type, id, account, statusViewData); } + + public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { + return new Concrete(type, id, account, statusViewData); + } } public static final class Placeholder extends NotificationViewData { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java deleted file mode 100644 index 10820fbd..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ /dev/null @@ -1,677 +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.viewdata; - -import android.os.Build; -import android.text.SpannableStringBuilder; -import android.text.Spanned; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Card; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Poll; -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. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link StatusViewData.Concrete} or a {@link StatusViewData.Placeholder}. - */ - -public abstract class StatusViewData { - - private StatusViewData() { } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(StatusViewData other); - - public static final class Concrete extends StatusViewData { - private static final char SOFT_HYPHEN = '\u00ad'; - private static final char ASCII_HYPHEN = '-'; - - private final String id; - private final Spanned content; - final boolean reblogged; - final boolean favourited; - final boolean bookmarked; - private final boolean muted; - @Nullable - private final String spoilerText; - private final Status.Visibility visibility; - private final List attachments; - @Nullable - private final String rebloggedByUsername; - @Nullable - private final String rebloggedAvatar; - private final boolean isSensitive; - final boolean isExpanded; - private final boolean isShowingContent; - private final String userFullName; - private final String nickname; - private final String avatar; - private final Date createdAt; - private final int reblogsCount; - private final int favouritesCount; - @Nullable - private final String inReplyToId; - // I would rather have something else but it would be too much of a rewrite - @Nullable - private final Status.Mention[] mentions; - private final String senderId; - private final boolean rebloggingEnabled; - private final Status.Application application; - private final List statusEmojis; - private final List accountEmojis; - private final List rebloggedByAccountEmojis; - @Nullable - private final Card card; - private final boolean isCollapsible; /** Whether the status meets the requirement to be collapse */ - final boolean isCollapsed; /** Whether the status is shown partially or fully */ - @Nullable - private final PollViewData poll; - private final boolean isBot; - - public Concrete(String id, Spanned content, boolean reblogged, boolean favourited, boolean bookmarked, boolean muted, - @Nullable String spoilerText, Status.Visibility visibility, List attachments, - @Nullable String rebloggedByUsername, @Nullable String rebloggedAvatar, boolean sensitive, boolean isExpanded, - boolean isShowingContent, String userFullName, String nickname, String avatar, - Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, - @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, - Status.Application application, List statusEmojis, List accountEmojis, List rebloggedByAccountEmojis, @Nullable Card card, - boolean isCollapsible, boolean isCollapsed, @Nullable PollViewData poll, boolean isBot) { - - this.id = id; - if (Build.VERSION.SDK_INT == 23) { - // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(content); - this.spoilerText = spoilerText == null ? null : replaceCrashingCharacters(spoilerText).toString(); - this.nickname = replaceCrashingCharacters(nickname).toString(); - } else { - this.content = content; - this.spoilerText = spoilerText; - this.nickname = nickname; - } - this.reblogged = reblogged; - this.favourited = favourited; - this.bookmarked = bookmarked; - this.muted = muted; - this.visibility = visibility; - this.attachments = attachments; - this.rebloggedByUsername = rebloggedByUsername; - this.rebloggedAvatar = rebloggedAvatar; - this.isSensitive = sensitive; - this.isExpanded = isExpanded; - this.isShowingContent = isShowingContent; - this.userFullName = userFullName; - this.avatar = avatar; - this.createdAt = createdAt; - this.reblogsCount = reblogsCount; - this.favouritesCount = favouritesCount; - this.inReplyToId = inReplyToId; - this.mentions = mentions; - this.senderId = senderId; - this.rebloggingEnabled = rebloggingEnabled; - this.application = application; - this.statusEmojis = statusEmojis; - this.accountEmojis = accountEmojis; - this.rebloggedByAccountEmojis = rebloggedByAccountEmojis; - this.card = card; - this.isCollapsible = isCollapsible; - this.isCollapsed = isCollapsed; - this.poll = poll; - this.isBot = isBot; - } - - public String getId() { - return id; - } - - public Spanned getContent() { - return content; - } - - public boolean isReblogged() { - return reblogged; - } - - public boolean isFavourited() { - return favourited; - } - - public boolean isBookmarked() { - return bookmarked; - } - - public boolean isMuted() { - return muted; - } - - @Nullable - public String getSpoilerText() { - return spoilerText; - } - - public Status.Visibility getVisibility() { - return visibility; - } - - public List getAttachments() { - return attachments; - } - - @Nullable - public String getRebloggedByUsername() { - return rebloggedByUsername; - } - - public boolean isSensitive() { - return isSensitive; - } - - public boolean isExpanded() { - return isExpanded; - } - - public boolean isShowingContent() { - return isShowingContent; - } - - public boolean isBot(){ return isBot; } - - @Nullable - public String getRebloggedAvatar() { - return rebloggedAvatar; - } - - public String getUserFullName() { - return userFullName; - } - - public String getNickname() { - return nickname; - } - - public String getAvatar() { - return avatar; - } - - public Date getCreatedAt() { - return createdAt; - } - - public int getReblogsCount() { - return reblogsCount; - } - - public int getFavouritesCount() { - return favouritesCount; - } - - @Nullable - public String getInReplyToId() { - return inReplyToId; - } - - public String getSenderId() { - return senderId; - } - - public Boolean getRebloggingEnabled() { - return rebloggingEnabled; - } - - @Nullable - public Status.Mention[] getMentions() { - return mentions; - } - - public Status.Application getApplication() { - return application; - } - - public List getStatusEmojis() { - return statusEmojis; - } - - public List getAccountEmojis() { - return accountEmojis; - } - - public List getRebloggedByAccountEmojis() { - return rebloggedByAccountEmojis; - } - - @Nullable - public Card getCard() { - return card; - } - - /** - * Specifies whether the content of this post is allowed to be collapsed or if it should show - * all content regardless. - * - * @return Whether the post is collapsible or never collapsed. - */ - public boolean isCollapsible() { - return isCollapsible; - } - - /** - * Specifies whether the content of this post is currently limited in visibility to the first - * 500 characters or not. - * - * @return Whether the post is collapsed or fully expanded. - */ - public boolean isCollapsed() { - return isCollapsed; - } - - @Nullable - public PollViewData getPoll() { - return poll; - } - - @Override public long getViewDataId() { - // Chance of collision is super low and impact of mistake is low as well - return id.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 && - bookmarked == concrete.bookmarked && - isSensitive == concrete.isSensitive && - isExpanded == concrete.isExpanded && - isShowingContent == concrete.isShowingContent && - isBot == concrete.isBot && - 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(statusEmojis, concrete.statusEmojis) && - Objects.equals(accountEmojis, concrete.accountEmojis) && - Objects.equals(rebloggedByAccountEmojis, concrete.rebloggedByAccountEmojis) && - Objects.equals(card, concrete.card) && - Objects.equals(poll, concrete.poll) - && isCollapsed == concrete.isCollapsed; - } - - static Spanned replaceCrashingCharacters(Spanned content) { - return (Spanned) replaceCrashingCharacters((CharSequence) content); - } - - static CharSequence replaceCrashingCharacters(CharSequence content) { - boolean replacing = false; - SpannableStringBuilder builder = null; - int length = content.length(); - - for (int index = 0; index < length; ++index) { - char character = content.charAt(index); - - // If there are more than one or two, switch to a map - if (character == SOFT_HYPHEN) { - if (!replacing) { - replacing = true; - builder = new SpannableStringBuilder(content, 0, index); - } - builder.append(ASCII_HYPHEN); - } else if (replacing) { - builder.append(character); - } - } - - return replacing ? builder : content; - } - } - - public static final class Placeholder extends StatusViewData { - private final boolean isLoading; - private final String id; - - public Placeholder(String id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - public String getId() { - return id; - } - - @Override public long getViewDataId() { - return id.hashCode(); - } - - @Override public boolean deepEquals(StatusViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id.equals(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 + id.hashCode(); - return result; - } - } - - public static class Builder { - private String id; - private Spanned content; - private boolean reblogged; - private boolean favourited; - private boolean bookmarked; - private boolean muted; - private String spoilerText; - private Status.Visibility visibility; - private List attachments; - private String rebloggedByUsername; - private String rebloggedAvatar; - private boolean isSensitive; - private boolean isExpanded; - private boolean isShowingContent; - private String userFullName; - private String nickname; - private String avatar; - private Date createdAt; - private int reblogsCount; - private int favouritesCount; - private String inReplyToId; - private Status.Mention[] mentions; - private String senderId; - private boolean rebloggingEnabled; - private Status.Application application; - private List statusEmojis; - private List accountEmojis; - private List rebloggedByAccountEmojis; - private Card card; - private boolean isCollapsible; /** Whether the status meets the requirement to be collapsed */ - private boolean isCollapsed; /** Whether the status is shown partially or fully */ - private PollViewData poll; - private boolean isBot; - - public Builder() { - } - - public Builder(final StatusViewData.Concrete viewData) { - id = viewData.id; - content = viewData.content; - reblogged = viewData.reblogged; - favourited = viewData.favourited; - bookmarked = viewData.bookmarked; - muted = viewData.muted; - spoilerText = viewData.spoilerText; - visibility = viewData.visibility; - attachments = viewData.attachments == null ? null : new ArrayList<>(viewData.attachments); - rebloggedByUsername = viewData.rebloggedByUsername; - rebloggedAvatar = viewData.rebloggedAvatar; - isSensitive = viewData.isSensitive; - isExpanded = viewData.isExpanded; - isShowingContent = viewData.isShowingContent; - userFullName = viewData.userFullName; - nickname = viewData.nickname; - avatar = viewData.avatar; - createdAt = new Date(viewData.createdAt.getTime()); - reblogsCount = viewData.reblogsCount; - favouritesCount = viewData.favouritesCount; - inReplyToId = viewData.inReplyToId; - mentions = viewData.mentions == null ? null : viewData.mentions.clone(); - senderId = viewData.senderId; - rebloggingEnabled = viewData.rebloggingEnabled; - application = viewData.application; - statusEmojis = viewData.getStatusEmojis(); - accountEmojis = viewData.getAccountEmojis(); - rebloggedByAccountEmojis = viewData.getRebloggedByAccountEmojis(); - card = viewData.getCard(); - isCollapsible = viewData.isCollapsible(); - isCollapsed = viewData.isCollapsed(); - poll = viewData.poll; - isBot = viewData.isBot(); - } - - public Builder setId(String id) { - this.id = id; - return this; - } - - public Builder setContent(Spanned content) { - this.content = content; - return this; - } - - public Builder setReblogged(boolean reblogged) { - this.reblogged = reblogged; - return this; - } - - public Builder setFavourited(boolean favourited) { - this.favourited = favourited; - return this; - } - - public Builder setBookmarked(boolean bookmarked) { - this.bookmarked = bookmarked; - return this; - } - - public Builder setMuted(boolean muted) { - this.muted = muted; - return this; - } - - public Builder setSpoilerText(String spoilerText) { - this.spoilerText = spoilerText; - return this; - } - - public Builder setVisibility(Status.Visibility visibility) { - this.visibility = visibility; - return this; - } - - public Builder setAttachments(List attachments) { - this.attachments = attachments; - return this; - } - - public Builder setRebloggedByUsername(String rebloggedByUsername) { - this.rebloggedByUsername = rebloggedByUsername; - return this; - } - - public Builder setRebloggedAvatar(String rebloggedAvatar) { - this.rebloggedAvatar = rebloggedAvatar; - return this; - } - - public Builder setSensitive(boolean sensitive) { - this.isSensitive = sensitive; - return this; - } - - public Builder setIsExpanded(boolean isExpanded) { - this.isExpanded = isExpanded; - return this; - } - - public Builder setIsShowingSensitiveContent(boolean isShowingSensitiveContent) { - this.isShowingContent = isShowingSensitiveContent; - return this; - } - - public Builder setIsBot(boolean isBot) { - this.isBot = isBot; - return this; - } - - public Builder setUserFullName(String userFullName) { - this.userFullName = userFullName; - return this; - } - - public Builder setNickname(String nickname) { - this.nickname = nickname; - return this; - } - - public Builder setAvatar(String avatar) { - this.avatar = avatar; - return this; - } - - public Builder setCreatedAt(Date createdAt) { - this.createdAt = createdAt; - return this; - } - - public Builder setReblogsCount(int reblogsCount) { - this.reblogsCount = reblogsCount; - return this; - } - - public Builder setFavouritesCount(int favouritesCount) { - this.favouritesCount = favouritesCount; - return this; - } - - public Builder setInReplyToId(String inReplyToId) { - this.inReplyToId = inReplyToId; - return this; - } - - public Builder setMentions(Status.Mention[] mentions) { - this.mentions = mentions; - return this; - } - - public Builder setSenderId(String senderId) { - this.senderId = senderId; - return this; - } - - public Builder setRebloggingEnabled(boolean rebloggingEnabled) { - this.rebloggingEnabled = rebloggingEnabled; - return this; - } - - public Builder setApplication(Status.Application application) { - this.application = application; - return this; - } - - public Builder setStatusEmojis(List emojis) { - this.statusEmojis = emojis; - return this; - } - - public Builder setAccountEmojis(List emojis) { - this.accountEmojis = emojis; - return this; - } - - public Builder setRebloggedByEmojis(List emojis) { - this.rebloggedByAccountEmojis = emojis; - return this; - } - - public Builder setCard(Card card) { - this.card = card; - return this; - } - - /** - * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to support collapsing - * its content limiting the visible length when collapsed at 500 characters, - * - * @param collapsible Whether the status should support being collapsed or not. - * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. - */ - public Builder setCollapsible(boolean collapsible) { - isCollapsible = collapsible; - return this; - } - - /** - * Configure the {@link com.keylesspalace.tusky.viewdata.StatusViewData} to start in a collapsed - * state, hiding partially the content of the post if it exceeds a certain amount of characters. - * - * @param collapsed Whether to show the full content of the status or not. - * @return This {@link com.keylesspalace.tusky.viewdata.StatusViewData.Builder} instance. - */ - public Builder setCollapsed(boolean collapsed) { - isCollapsed = collapsed; - return this; - } - - public Builder setPoll(Poll poll) { - this.poll = PollViewDataKt.toViewData(poll); - return this; - } - - public StatusViewData.Concrete createStatusViewData() { - if (this.statusEmojis == null) statusEmojis = Collections.emptyList(); - if (this.accountEmojis == null) accountEmojis = Collections.emptyList(); - if (this.createdAt == null) createdAt = new Date(); - - return new StatusViewData.Concrete(id, content, reblogged, favourited, bookmarked, muted, spoilerText, - visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, - isShowingContent, userFullName, nickname, avatar, createdAt, reblogsCount, - favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, - statusEmojis, accountEmojis, rebloggedByAccountEmojis, card, isCollapsible, isCollapsed, poll, isBot); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt new file mode 100644 index 00000000..d02569d4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -0,0 +1,144 @@ +/* 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.viewdata + +import android.os.Build +import android.text.SpannableStringBuilder +import android.text.Spanned +import com.keylesspalace.tusky.entity.Status + +/** + * Created by charlag on 11/07/2017. + * + * + * Class to represent data required to display either a notification or a placeholder. + * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. + */ +sealed class StatusViewData private constructor() { + abstract val viewDataId: Long + + data class Concrete( + val status: Status, + val isExpanded: Boolean, + val isShowingContent: Boolean, + /** + * Specifies whether the content of this post is allowed to be collapsed or if it should show + * all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + val isCollapsible: Boolean, + /** + * Specifies whether the content of this post is currently limited in visibility to the first + * 500 characters or not. + * + * @return Whether the post is collapsed or fully expanded. + */ + /** Whether the status meets the requirement to be collapse */ + val isCollapsed: Boolean, + ) : StatusViewData() { + override val viewDataId: Long + get() = status.id.hashCode().toLong() + + val content: Spanned + val spoilerText: String + val username: String + + val actionable: Status + get() = status.actionableStatus + + val rebloggedAvatar: String? + get() = status.reblog?.account?.avatar + + val rebloggingStatus: Status? + get() = if (status.reblog != null) status else null + + init { + if (Build.VERSION.SDK_INT == 23) { + // https://github.com/tuskyapp/Tusky/issues/563 + this.content = replaceCrashingCharacters(status.actionableStatus.content) + this.spoilerText = + replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() + this.username = + replaceCrashingCharacters(status.actionableStatus.account.username).toString() + } else { + this.content = status.actionableStatus.content + this.spoilerText = status.actionableStatus.spoilerText + this.username = status.actionableStatus.account.username + } + } + + companion object { + private const val SOFT_HYPHEN = '\u00ad' + private const val ASCII_HYPHEN = '-' + fun replaceCrashingCharacters(content: Spanned): Spanned { + return replaceCrashingCharacters(content as CharSequence) as Spanned + } + + fun replaceCrashingCharacters(content: CharSequence?): CharSequence? { + var replacing = false + var builder: SpannableStringBuilder? = null + val length = content!!.length + for (index in 0 until length) { + val character = content[index] + + // If there are more than one or two, switch to a map + if (character == SOFT_HYPHEN) { + if (!replacing) { + replacing = true + builder = SpannableStringBuilder(content, 0, index) + } + builder!!.append(ASCII_HYPHEN) + } else if (replacing) { + builder!!.append(character) + } + } + return if (replacing) builder else content + } + } + + val id: String + get() = status.id + + /** Helper for Java */ + fun copyWithStatus(status: Status): Concrete { + return copy(status = status) + } + + /** Helper for Java */ + fun copyWithExpanded(isExpanded: Boolean): Concrete { + return copy(isExpanded = isExpanded) + } + + /** Helper for Java */ + fun copyWithShowingContent(isShowingContent: Boolean): Concrete { + return copy(isShowingContent = isShowingContent) + } + + /** Helper for Java */ + fun copyWIthCollapsed(isCollapsed: Boolean): Concrete { + return copy(isCollapsed = isCollapsed) + } + } + + data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() { + override val viewDataId: Long + get() = id.hashCode().toLong() + } + + fun asStatusOrNull() = this as? Concrete + + fun asPlaceholderOrNull() = this as? Placeholder +} diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 4ff64536..3b72d2ae 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -91,7 +91,7 @@ class BottomSheetActivityTest { "", Status.Visibility.PUBLIC, ArrayList(), - arrayOf(), + listOf(), null, pinned = false, muted = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index 6b35d4a4..71f0377f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -1,260 +1,186 @@ package com.keylesspalace.tusky -import android.os.Bundle import android.text.SpannedString import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi +import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock -import okhttp3.Request -import okio.Timeout +import io.reactivex.rxjava3.core.Single import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito -import org.robolectric.Robolectric import org.robolectric.annotation.Config -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import java.util.* @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) class FilterTest { - private val fragment = FakeFragment() + lateinit var filterModel: FilterModel @Before fun setup() { + filterModel = FilterModel() + val filters = listOf( + Filter( + id = "123", + phrase = "badWord", + context = listOf(Filter.HOME), + expiresAt = null, + irreversible = false, + wholeWord = false + ), + Filter( + id = "123", + phrase = "badWholeWord", + context = listOf(Filter.HOME, Filter.PUBLIC), + expiresAt = null, + irreversible = false, + wholeWord = true + ), + Filter( + id = "123", + phrase = "@twitter.com", + context = listOf(Filter.HOME), + expiresAt = null, + irreversible = false, + wholeWord = true + ) + ) - val controller = Robolectric.buildActivity(FakeActivity::class.java) - val activity = controller.get() - - activity.accountManager = mock() - val apiMock = Mockito.mock(MastodonApi::class.java) - Mockito.`when`(apiMock.getFilters()).thenReturn(object: Call> { - override fun isExecuted(): Boolean { - return false - } - override fun clone(): Call> { - throw Error("not implemented") - } - override fun isCanceled(): Boolean { - throw Error("not implemented") - } - override fun cancel() { - throw Error("not implemented") - } - override fun execute(): Response> { - throw Error("not implemented") - } - override fun request(): Request { - throw Error("not implemented") - } - - override fun enqueue(callback: Callback>) { - callback.onResponse( - this, - Response.success( - listOf( - Filter( - id = "123", - phrase = "badWord", - context = listOf(Filter.HOME), - expiresAt = null, - irreversible = false, - wholeWord = false - ), - Filter( - id = "123", - phrase = "badWholeWord", - context = listOf(Filter.HOME, Filter.PUBLIC), - expiresAt = null, - irreversible = false, - wholeWord = true - ), - Filter( - id = "123", - phrase = "wrongContext", - context = listOf(Filter.PUBLIC), - expiresAt = null, - irreversible = false, - wholeWord = true - ), - Filter( - id = "123", - phrase = "@twitter.com", - context = listOf(Filter.HOME), - expiresAt = null, - irreversible = false, - wholeWord = true - ) - ) - ) - ) - } - - override fun timeout(): Timeout { - throw Error("not implemented") - } - }) - - activity.mastodonApi = apiMock - - - controller.create().start() - - fragment.mastodonApi = apiMock - - - activity.supportFragmentManager.beginTransaction() - .replace(R.id.mainDrawerLayout, fragment, "fragment") - .commit() - - fragment.reloadFilters(false) - + filterModel.initWithFilters(filters) } @Test fun shouldNotFilter() { - assertFalse(fragment.shouldFilterStatus( + assertFalse( + filterModel.shouldFilterStatus( mockStatus(content = "should not be filtered") - )) - } - - @Test - fun shouldNotFilter_whenContextDoesNotMatch() { - assertFalse(fragment.shouldFilterStatus( - mockStatus(content = "one two wrongContext three") - )) + ) + ) } @Test fun shouldFilter_whenContentMatchesBadWord() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWord three") - )) + ) + ) } @Test fun shouldFilter_whenContentMatchesBadWordPart() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWordPart three") - )) + ) + ) } @Test fun shouldFilter_whenContentMatchesBadWholeWord() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWholeWord three") - )) + ) + ) } @Test fun shouldNotFilter_whenContentDoesNotMatchWholeWord() { - assertFalse(fragment.shouldFilterStatus( + assertFalse( + filterModel.shouldFilterStatus( mockStatus(content = "one two badWholeWordTest three") - )) + ) + ) } @Test fun shouldFilter_whenSpoilerTextDoesMatch() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus( - content = "should not be filtered", - spoilerText = "badWord should be filtered" + content = "should not be filtered", + spoilerText = "badWord should be filtered" ) - )) + ) + ) } @Test fun shouldFilter_whenPollTextDoesMatch() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus( - content = "should not be filtered", - spoilerText = "should not be filtered", - pollOptions = listOf("should not be filtered", "badWord") + content = "should not be filtered", + spoilerText = "should not be filtered", + pollOptions = listOf("should not be filtered", "badWord") ) - )) + ) + ) } @Test fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() { - assertTrue(fragment.shouldFilterStatus( + assertTrue( + filterModel.shouldFilterStatus( mockStatus(content = "one two someone@twitter.com three") - )) - } - - private fun mockStatus( - content: String = "", - spoilerText: String = "", - pollOptions: List? = null - ): Status { - return Status( - id = "123", - url = "https://mastodon.social/@Tusky/100571663297225812", - account = mock(), - inReplyToId = null, - inReplyToAccountId = null, - reblog = null, - content = SpannedString(content), - createdAt = Date(), - emojis = emptyList(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = spoilerText, - visibility = Status.Visibility.PUBLIC, - attachments = arrayListOf(), - mentions = emptyArray(), - application = null, - pinned = false, - muted = false, - poll = if (pollOptions != null) { - Poll( - id = "1234", - expiresAt = null, - expired = false, - multiple = false, - votesCount = 0, - votersCount = 0, - options = pollOptions.map { - PollOption(it, 0) - }, - voted = false - ) - } else null, - card = null + ) ) } -} - -class FakeActivity: BottomSheetActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} - -class FakeFragment: SFragment() { - override fun removeItem(position: Int) { + private fun mockStatus( + content: String = "", + spoilerText: String = "", + pollOptions: List? = null + ): Status { + return Status( + id = "123", + url = "https://mastodon.social/@Tusky/100571663297225812", + account = mock(), + inReplyToId = null, + inReplyToAccountId = null, + reblog = null, + content = SpannedString(content), + createdAt = Date(), + emojis = emptyList(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = spoilerText, + visibility = Status.Visibility.PUBLIC, + attachments = arrayListOf(), + mentions = listOf(), + application = null, + pinned = false, + muted = false, + poll = if (pollOptions != null) { + Poll( + id = "1234", + expiresAt = null, + expired = false, + multiple = false, + votesCount = 0, + votersCount = 0, + options = pollOptions.map { + PollOption(it, 0) + }, + voted = false + ) + } else null, + card = null + ) } - override fun onReblog(reblog: Boolean, position: Int) { - } - - override fun filterIsRelevant(filter: Filter): Boolean { - return filter.context.contains(Filter.HOME) - } } \ No newline at end of file diff --git a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt similarity index 60% rename from app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt rename to app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt index 27ff5f62..a11fda67 100644 --- a/app/src/test/java/com/keylesspalace/tusky/fragment/TimelineRepositoryTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.keylesspalace.tusky.fragment +package com.keylesspalace.tusky.components.timeline import android.text.SpannableString import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -10,7 +10,6 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.repository.* import com.keylesspalace.tusky.util.Either import com.nhaarman.mockitokotlin2.isNull import com.nhaarman.mockitokotlin2.verify @@ -54,10 +53,10 @@ class TimelineRepositoryTest { private val limit = 30 private val account = AccountEntity( - id = 2, - accessToken = "token", - domain = "domain.com", - isActive = true + id = 2, + accessToken = "token", + domain = "domain.com", + isActive = true ) @Before @@ -74,13 +73,13 @@ class TimelineRepositoryTest { @Test fun testNetworkUnbounded() { val statuses = listOf( - makeStatus("3"), - makeStatus("2") + makeStatus("3"), + makeStatus("2") ) whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt())) - .thenReturn(Single.just(Response.success(statuses))) + .thenReturn(Single.just(Response.success(statuses))) val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK) - .blockingGet() + .blockingGet() assertEquals(statuses.map(Status::lift), result) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) @@ -90,9 +89,9 @@ class TimelineRepositoryTest { verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id)) for (status in statuses) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } verify(timelineDao).cleanup(anyLong()) @@ -102,34 +101,38 @@ class TimelineRepositoryTest { @Test fun testNetworkLoadingTopNoGap() { val response = listOf( - makeStatus("4"), - makeStatus("3"), - makeStatus("2") + makeStatus("4"), + makeStatus("3"), + makeStatus("2") ) val sinceId = "2" val sinceIdMinusOne = "1" whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + null, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() assertEquals( - response.subList(0, 2).map(Status::lift), - result + response.subList(0, 2).map(Status::lift), + result ) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) // We assume for now that overlapped one is inserted but it's not that important for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) + verify(timelineDao).removeAllPlaceholdersBetween( + account.id, response.first().id, + response.last().id + ) verify(timelineDao).cleanup(anyLong()) verifyNoMoreInteractions(timelineDao) } @@ -137,16 +140,18 @@ class TimelineRepositoryTest { @Test fun testNetworkLoadingTopWithGap() { val response = listOf( - makeStatus("5"), - makeStatus("4") + makeStatus("5"), + makeStatus("4") ) val sinceId = "2" val sinceIdMinusOne = "1" whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + null, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() val placeholder = Placeholder("3") assertEquals(response.map(Status::lift) + Either.Left(placeholder), result) @@ -154,9 +159,9 @@ class TimelineRepositoryTest { verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) @@ -174,36 +179,40 @@ class TimelineRepositoryTest { // 1 val response = listOf( - makeStatus("5"), - makeStatus("4"), - makeStatus("3"), - makeStatus("2") + makeStatus("5"), + makeStatus("4"), + makeStatus("3"), + makeStatus("2") ) val sinceId = "2" val sinceIdMinusOne = "1" val maxId = "3" whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + maxId, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() assertEquals( - response.subList(0, response.lastIndex).map(Status::lift), - result + response.subList(0, response.lastIndex).map(Status::lift), + result ) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) // We assume for now that overlapped one is inserted but it's not that important for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) + verify(timelineDao).removeAllPlaceholdersBetween( + account.id, response.first().id, + response.last().id + ) verify(timelineDao).cleanup(anyLong()) verifyNoMoreInteractions(timelineDao) } @@ -218,23 +227,25 @@ class TimelineRepositoryTest { // 1 val response = listOf( - makeStatus("6"), - makeStatus("5"), - makeStatus("4") + makeStatus("6"), + makeStatus("5"), + makeStatus("4") ) val sinceId = "2" val sinceIdMinusOne = "1" val maxId = "4" whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK) - .blockingGet() + .thenReturn(Single.just(Response.success(response))) + val result = subject.getStatuses( + maxId, sinceId, sinceIdMinusOne, limit, + TimelineRequestMode.NETWORK + ) + .blockingGet() val placeholder = Placeholder("3") assertEquals( - response.map(Status::lift) + Either.Left(placeholder), - result + response.map(Status::lift) + Either.Left(placeholder), + result ) testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) // We assume for now that overlapped one is inserted but it's not that important @@ -243,13 +254,15 @@ class TimelineRepositoryTest { for (status in response) { verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null + status.toEntity(account.id, gson), + status.account.toEntity(account.id, gson), + null ) } - verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id, - response.last().id) + verify(timelineDao).removeAllPlaceholdersBetween( + account.id, response.first().id, + response.last().id + ) verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) verify(timelineDao).cleanup(anyLong()) verifyNoMoreInteractions(timelineDao) @@ -265,11 +278,11 @@ class TimelineRepositoryTest { dbResult.account = status.account.toEntity(account.id, gson) whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success((listOf(status))))) + .thenReturn(Single.just(Response.success((listOf(status))))) whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult))) + .thenReturn(Single.just(listOf(dbResult))) val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() + .blockingGet() assertEquals(listOf(status, dbStatus).map(Status::lift), result) } @@ -283,60 +296,60 @@ class TimelineRepositoryTest { dbResult2.status = Placeholder("1").toEntity(account.id) whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success(listOf(status)))) + .thenReturn(Single.just(Response.success(listOf(status)))) whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult, dbResult2))) + .thenReturn(Single.just(listOf(dbResult, dbResult2))) val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() + .blockingGet() assertEquals(listOf(status).map(Status::lift), result) } +} - private fun makeStatus(id: String, account: Account = makeAccount(id)): Status { - return Status( - id = id, - account = account, - content = SpannableString("hello$id"), - createdAt = Date(), - emojis = listOf(), - reblogsCount = 3, - favouritesCount = 5, - sensitive = false, - visibility = Status.Visibility.PUBLIC, - spoilerText = "", - reblogged = true, - favourited = false, - bookmarked = false, - attachments = ArrayList(), - mentions = arrayOf(), - application = null, - inReplyToAccountId = null, - inReplyToId = null, - pinned = false, - muted = false, - reblog = null, - url = "http://example.com/statuses/$id", - poll = null, - card = null - ) - } +fun makeAccount(id: String): Account { + return Account( + id = id, + localUsername = "test$id", + username = "test$id@example.com", + displayName = "Example Account $id", + note = SpannableString("Note! $id"), + url = "https://example.com/@test$id", + avatar = "avatar$id", + header = "Header$id", + followersCount = 300, + followingCount = 400, + statusesCount = 1000, + bot = false, + emojis = listOf(), + fields = null, + source = null + ) +} - private fun makeAccount(id: String): Account { - return Account( - id = id, - localUsername = "test$id", - username = "test$id@example.com", - displayName = "Example Account $id", - note = SpannableString("Note! $id"), - url = "https://example.com/@test$id", - avatar = "avatar$id", - header = "Header$id", - followersCount = 300, - followingCount = 400, - statusesCount = 1000, - bot = false, - emojis = listOf(), - fields = null, - source = null - ) - } -} \ No newline at end of file +fun makeStatus(id: String, account: Account = makeAccount(id)): Status { + return Status( + id = id, + account = account, + content = SpannableString("hello$id"), + createdAt = Date(), + emojis = listOf(), + reblogsCount = 3, + favouritesCount = 5, + sensitive = false, + visibility = Status.Visibility.PUBLIC, + spoilerText = "", + reblogged = true, + favourited = false, + bookmarked = false, + attachments = ArrayList(), + mentions = listOf(), + application = null, + inReplyToAccountId = null, + inReplyToId = null, + pinned = false, + muted = false, + reblog = null, + url = "http://example.com/statuses/$id", + poll = null, + card = null + ) +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt new file mode 100644 index 00000000..b70972db --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt @@ -0,0 +1,783 @@ +package com.keylesspalace.tusky.components.timeline + +import android.content.SharedPreferences +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.PollOption +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import com.nhaarman.mockitokotlin2.* +import io.reactivex.rxjava3.annotations.NonNull +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.observers.TestObserver +import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog +import retrofit2.Response +import java.io.IOException + + +@Config(sdk = [29]) +class TimelineViewModelTest { + lateinit var timelineRepository: TimelineRepository + lateinit var timelineCases: TimelineCases + lateinit var mastodonApi: MastodonApi + lateinit var eventHub: EventHub + lateinit var viewModel: TimelineViewModel + lateinit var accountManager: AccountManager + lateinit var sharedPreference: SharedPreferences + + @Before + fun setup() { + ShadowLog.stream = System.out + timelineRepository = mock() + timelineCases = mock() + mastodonApi = mock() + eventHub = mock { + on { events } doReturn Observable.never() + } + val account = AccountEntity( + 0, + "domain", + "accessToken", + isActive = true, + ) + + accountManager = mock { + on { activeAccount } doReturn account + } + sharedPreference = mock() + viewModel = TimelineViewModel( + timelineRepository, + timelineCases, + mastodonApi, + eventHub, + accountManager, + sharedPreference, + FilterModel() + ) + } + + @Test + fun `loadInitial, home, without cache, empty response`() { + val initialResponse = listOf() + setCachedResponse(initialResponse) + + // loadAbove -> loadBelow + whenever( + timelineRepository.getStatuses( + maxId = null, + sinceId = null, + sincedIdMinusOne = null, + requestMode = TimelineRequestMode.ANY, + limit = LOAD_AT_ONCE + ) + ).thenReturn(Single.just(listOf())) + + runBlocking { + viewModel.loadInitial() + } + + verify(timelineRepository).getStatuses( + null, + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + } + + @Test + fun `loadInitial, home, without cache, single item in response`() { + setCachedResponse(listOf()) + + val status = makeStatus("1") + whenever( + timelineRepository.getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.ANY) + ) + ).thenReturn( + Single.just( + listOf( + Either.Right(status) + ) + ) + ) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + verify(timelineRepository).getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.ANY) + ) + + assertViewUpdated(updates) + + assertHasList(listOf(status).toViewData()) + } + + @Test + fun `loadInitial, list`() { + val listId = "listId" + viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf()) + val status = makeStatus("1") + + whenever( + mastodonApi.listTimeline( + listId, + null, + null, + LOAD_AT_ONCE, + ) + ).thenReturn( + Single.just( + Response.success( + listOf( + status + ) + ) + ) + ) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial().join() + } + assertViewUpdated(updates) + + assertHasList(listOf(status).toViewData()) + assertFalse("loading", viewModel.isLoadingInitially) + } + + @Test + fun `loadInitial, home, without cache, error on load`() { + setCachedResponse(listOf()) + + whenever( + timelineRepository.getStatuses( + maxId = null, + sinceId = null, + sincedIdMinusOne = null, + limit = LOAD_AT_ONCE, + TimelineRequestMode.ANY, + ) + ).thenReturn(Single.error(IOException("test"))) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + verify(timelineRepository).getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.ANY) + ) + + assertViewUpdated(updates) + + assertHasList(listOf()) + assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure) + } + + @Test + fun `loadInitial, home, with cache, error on load above`() { + val statuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + + whenever( + timelineRepository.getStatuses( + maxId = null, + sinceId = "5", + sincedIdMinusOne = "4", + limit = LOAD_AT_ONCE, + TimelineRequestMode.NETWORK, + ) + ).thenReturn(Single.error(IOException("test"))) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + assertViewUpdated(updates) + + assertHasList(statuses.toViewData()) + // No failure set since we had statuses + assertNull(viewModel.failure) + } + + @Test + fun `loadInitial, home, with cache, error on refresh`() { + val statuses = (5 downTo 2).map { makeStatus(it.toString()) } + setCachedResponse(statuses) + + // Error on refreshing cached + whenever( + timelineRepository.getStatuses( + maxId = "6", + sinceId = null, + sincedIdMinusOne = null, + limit = LOAD_AT_ONCE, + TimelineRequestMode.NETWORK, + ) + ).thenReturn(Single.error(IOException("test"))) + + // Empty on loading above + setLoadAbove("5", "4", listOf()) + + val updates = viewModel.viewUpdates.test() + + runBlocking { + viewModel.loadInitial() + } + + assertViewUpdated(updates) + + assertHasList(statuses.toViewData()) + assertNull(viewModel.failure) + } + + @Test + fun `loads above cached`() { + val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("6", cachedStatuses) + + val additionalStatuses = (10 downTo 6) + .map { makeStatus(it.toString()) } + + whenever( + timelineRepository.getStatuses( + null, + "5", + "4", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(additionalStatuses.toEitherList())) + + runBlocking { + viewModel.loadInitial() + } + + // We could also check refresh progress here but it's a bit cumbersome + + assertHasList(additionalStatuses.plus(cachedStatuses).toViewData()) + } + + @Test + fun refresh() { + val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("6", cachedStatuses) + + val additionalStatuses = listOf(makeStatus("6")) + + whenever( + timelineRepository.getStatuses( + null, + "5", + "4", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(additionalStatuses.toEitherList())) + + runBlocking { + viewModel.loadInitial() + } + + clearInvocations(timelineRepository) + + val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) } + + // Loading above the cached manually + whenever( + timelineRepository.getStatuses( + null, + "6", + "5", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(newStatuses.toEitherList())) + + runBlocking { + viewModel.refresh() + } + + val allStatuses = newStatuses + additionalStatuses + cachedStatuses + assertHasList(allStatuses.toViewData()) + } + + @Test + fun `refresh failed`() { + val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("6", cachedStatuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { + viewModel.loadInitial() + } + + clearInvocations(timelineRepository) + + // Loading above the cached manually + whenever( + timelineRepository.getStatuses( + null, + "6", + "5", + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.error(IOException("test"))) + + runBlocking { + viewModel.refresh().join() + } + + assertHasList(cachedStatuses.map { it.toViewData(false, false) }) + assertFalse("refreshing", viewModel.isRefreshing) + assertNull("failure is not set", viewModel.failure) + } + + @Test + fun loadMore() { + val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("11", cachedStatuses) + + // Nothing above + setLoadAbove("10", "9", listOf()) + + runBlocking { + viewModel.loadInitial().join() + } + + clearInvocations(timelineRepository) + + val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } + + // Loading below the cached + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(Single.just(oldStatuses.toEitherList())) + + runBlocking { + viewModel.loadMore().join() + } + + val allStatuses = cachedStatuses + oldStatuses + assertHasList(allStatuses.toViewData()) + } + + @Test + fun `loadMore parallel`() { + val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("11", cachedStatuses) + + // Nothing above + setLoadAbove("10", "9", listOf()) + + runBlocking { + viewModel.loadInitial().join() + } + + clearInvocations(timelineRepository) + + val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } + + val responseSubject = PublishSubject.create>() + // Loading below the cached + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(responseSubject.firstOrError()) + + clearInvocations(timelineRepository) + + runBlocking { + // Trigger them in parallel + val job1 = viewModel.loadMore() + val job2 = viewModel.loadMore() + // Send the response + responseSubject.onNext(oldStatuses.toEitherList()) + // Wait for both + job1.join() + job2.join() + } + + val allStatuses = cachedStatuses + oldStatuses + assertHasList(allStatuses.toViewData()) + + verify(timelineRepository, times(1)).getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + } + + @Test + fun `loadMore failed`() { + val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } + setCachedResponse(cachedStatuses) + setInitialRefresh("11", cachedStatuses) + + // Nothing above + setLoadAbove("10", "9", listOf()) + + runBlocking { + viewModel.loadInitial().join() + } + + clearInvocations(timelineRepository) + + // Loading below the cached + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(Single.error(IOException("test"))) + + runBlocking { + viewModel.loadMore().join() + } + + assertHasList(cachedStatuses.toViewData()) + + // Check that we can still load after that + + val oldStatuses = listOf(makeStatus("4")) + whenever( + timelineRepository.getStatuses( + "5", + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.ANY + ) + ).thenReturn(Single.just(oldStatuses.toEitherList())) + + runBlocking { + viewModel.loadMore().join() + } + assertHasList((cachedStatuses + oldStatuses).toViewData()) + } + + @Test + fun loadGap() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val status1 = makeStatus("1") + + val cachedStatuses: List = listOf( + Either.Right(status5), + Either.Left(Placeholder("4")), + Either.Right(status1) + ) + val laterFetchedStatuses = listOf( + Either.Right(status4), + Either.Right(status3), + ) + + setCachedResponseWithGaps(cachedStatuses) + setInitialRefreshWithGaps("6", cachedStatuses) + + // Nothing above + setLoadAbove("5", items = listOf()) + + whenever( + timelineRepository.getStatuses( + "5", + "1", + null, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(laterFetchedStatuses)) + + runBlocking { + viewModel.loadInitial().join() + + viewModel.loadGap(1).join() + } + + assertHasList( + listOf( + status5, + status4, + status3, + status1 + ).toViewData() + ) + } + + @Test + fun `loadGap failed`() { + val status5 = makeStatus("5") + val status1 = makeStatus("1") + + val cachedStatuses: List = listOf( + Either.Right(status5), + Either.Left(Placeholder("4")), + Either.Right(status1) + ) + setCachedResponseWithGaps(cachedStatuses) + setInitialRefreshWithGaps("6", cachedStatuses) + + setLoadAbove("5", items = listOf()) + + whenever( + timelineRepository.getStatuses( + "5", + "1", + null, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.error(IOException("test"))) + + runBlocking { + viewModel.loadInitial().join() + + viewModel.loadGap(1).join() + } + + assertHasList( + listOf( + status5.toViewData(false, false), + StatusViewData.Placeholder("4", false), + status1.toViewData(false, false), + ) + ) + } + + @Test + fun favorite() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + whenever(timelineCases.favourite("4", true)) + .thenReturn(Single.just(status4.copy(favourited = true))) + + runBlocking { + viewModel.favorite(true, 1).join() + } + + verify(timelineCases).favourite("4", true) + + assertHasList(listOf(status5, status4.copy(favourited = true), status3).toViewData()) + } + + @Test + fun reblog() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + whenever(timelineCases.reblog("4", true)) + .thenReturn(Single.just(status4.copy(reblogged = true))) + + runBlocking { + viewModel.reblog(true, 1).join() + } + + verify(timelineCases).reblog("4", true) + + assertHasList(listOf(status5, status4.copy(reblogged = true), status3).toViewData()) + } + + @Test + fun bookmark() { + val status5 = makeStatus("5") + val status4 = makeStatus("4") + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + whenever(timelineCases.bookmark("4", true)) + .thenReturn(Single.just(status4.copy(bookmarked = true))) + + runBlocking { + viewModel.bookmark(true, 1).join() + } + + verify(timelineCases).bookmark("4", true) + + assertHasList(listOf(status5, status4.copy(bookmarked = true), status3).toViewData()) + } + + @Test + fun voteInPoll() { + val status5 = makeStatus("5") + val poll = Poll( + "1", + expiresAt = null, + expired = false, + multiple = false, + votersCount = 1, + votesCount = 1, + voted = false, + options = listOf(PollOption("1", 1), PollOption("2", 2)), + ) + val status4 = makeStatus("4").copy(poll = poll) + val status3 = makeStatus("3") + val statuses = listOf(status5, status4, status3) + setCachedResponse(statuses) + setInitialRefresh("6", statuses) + setLoadAbove("5", "4", listOf()) + + runBlocking { viewModel.loadInitial() } + + val votedPoll = poll.votedCopy(listOf(0)) + whenever(timelineCases.voteInPoll("4", poll.id, listOf(0))) + .thenReturn(Single.just(votedPoll)) + + runBlocking { + viewModel.voteInPoll(1, listOf(0)).join() + } + + verify(timelineCases).voteInPoll("4", poll.id, listOf(0)) + + assertHasList(listOf(status5, status4.copy(poll = votedPoll), status3).toViewData()) + } + + private fun setLoadAbove( + above: String, + aboveMinusOne: String? = null, + items: List + ) { + whenever( + timelineRepository.getStatuses( + null, + above, + aboveMinusOne, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(items)) + } + + + private fun assertHasList(aList: List) { + assertEquals( + aList, + viewModel.statuses.toList() + ) + } + + private fun assertViewUpdated(updates: @NonNull TestObserver) { + assertTrue("There were view updates", updates.values().isNotEmpty()) + } + + private fun setInitialRefresh(maxId: String?, statuses: List) { + setInitialRefreshWithGaps(maxId, statuses.toEitherList()) + } + + private fun setCachedResponse(initialResponse: List) { + setCachedResponseWithGaps(initialResponse.toEitherList()) + } + + private fun setCachedResponseWithGaps(initialResponse: List) { + whenever( + timelineRepository.getStatuses( + isNull(), + isNull(), + isNull(), + eq(LOAD_AT_ONCE), + eq(TimelineRequestMode.DISK) + ) + ) + .thenReturn(Single.just(initialResponse)) + } + + private fun setInitialRefreshWithGaps(maxId: String?, statuses: List) { + whenever( + timelineRepository.getStatuses( + maxId, + null, + null, + LOAD_AT_ONCE, + TimelineRequestMode.NETWORK + ) + ).thenReturn(Single.just(statuses)) + } + + private fun List.toViewData(): List = map { + it.toViewData( + alwaysShowSensitiveMedia = false, + alwaysOpenSpoiler = false + ) + } + + private fun List.toEitherList() = map { Either.Right(it) } +} \ No newline at end of file