Browse Source

Convert NotificationsFragment and related code to Kotlin, use the Paging library (#3159)

* Unmodified output from "Convert Java to Kotlin" on NotificationsFragment.java

* Bare minimum changes to get this to compile and run

- Use `lateinit` for `eventhub`, `adapter`, `preferences`, and `scrolllistener`
- Removed override for accountManager, it can be used from the superclass
- Add `?.` where non-nullity could not (yet) be guaranteed
- Remove `?` from type lists where non-nullity is guaranteed
- Explicitly convert lists to mutable where necessary
- Delete unused function `findReplyPosition`

* Remove all unnecessary non-null (!!) assertions

The previous change meant some values are no longer nullable. Remove the
non-null assertions.

* Lint ListStatusAccessibilityDelegate call

- Remove redundant constructor
- Move block outside of `()`

* Use `let` when handling compose button visibility on scroll

* Replace a `requireNonNull` with `!!`

* Remove redundant return values

* Remove or rename unused lambda parameters

* Remove unnecessary type parameters

* Remove unnecessary null checks

* Replace cascading-if statement with `when`

* Simplify calculation of `topId`

* Use more appropriate list properties and methods

- Access the last value with `.last()`
- Access the last index with `.lastIndex`
- Replace logical-chain with `asRightOrNull` and `?.`
- `.isNotEmpty()`, not `!...isEmpty()`

* Inline unnecessary variable

* Use PrefKeys constants instead of bare strings

* Use `requireContext()` instead of `context!!`

* Replace deprecated `onActivityCreated()` with `onViewCreated()`

* Remove unnecessary variable setting

* Replace `size == 0` check with `isEmpty()`

* Format with ktlint, no functionality changes

* Convert NotifcationsAdapter to Kotlin

Does not compile, this is the unchanged output of the "Convert to Kotlin"
function

* Minimum changes to get NotificationsAdapter to compile

* Remove unnecessary visibility modifiers

* Use `isNotEmpty()`

* Remove unused lambda parameters

* Convert cascading-if to `when`

* Simplifiy assignment op

* Use explicit argument names with `copy()`

* Use `.firstOrNull()` instead of `if`

* Mark as lateinit to avoid unnecessary null checks

* Format with ktlint, whitespace changes only

* Bare minimum necessary to demonstrate paging in notifications

Create `NotificationsPagingSource`. This uses a new `notifications2()` API
call, which will exist until all the code has been adapted. Instead of
using placeholders,

Create `NotificationsPagingAdapter` (will replace `NotificationsAdapater`)
to consume this data.

Expose the paging source view a new `NotificationsViewModel` `flow`, and
submit new pages to the adapter as they are available in
`NotificationsFragment`.

Comment out any other code in `NotificationsFragment` that deals with
loading data from the network. This will be updated as necessary, either
here, or in the view model.

Lots of functionality is missing, including:

- Different views for different notification types
- Starting at the remembered notification position
- Interacting with notifications
- Adjusting the UI state to match the loading state

These will be added incrementally.

* Migrate StatusNotificationViewHolder impl. to NotificationsPagingAdapter

With this change `NotificationsPagingAdapter` shows notifications about a
status correctly.

- Introduce a `ViewHolder` abstract class that all Notification view holders
  derive from. Modify the fallback view holder to use this.

- Implement `StatusNotificationViewHolder`. Much of the code is from the
  existing implementation in the `NotificationAdapater`.

- The original code split the code that binds values to views between the
  adapter's `bindViewHolder` method and the view holder's methods.

  In this code, all of the binding code is in the view holder, in a `bind`
  method. This is called by the adapter's `bindViewHolder` method. This keeps
  all the binding logic in the view holder, where it belongs.

- The new `StatusNotificationViewHolder` uses view binding to access its views
  instead of `findViewById`.

- Logically, information about whether to show sensitive media, or open
  content warnings should be part of the `StatusDisplayOptions`. So add those
  as fields, and populate them appropriately.

  This affects code outside notification handling, which will be adjusted
  later.

* Note some TODOs to complete before the PR is finished

* Extract StatusNotificationViewHolder to a new file

* Add TODO for NotificationViewData.Concrete

* Convert the adapter to take NotificationViewData.Concrete

* Add a view holder for regular status notifications

* Migrate Follow and FollowRequest notifications

* Migrate report notifications

* Convert onViewThread to use the adapter data

* Convert onViewMedia to use the adapter data

* Convert onMore to use the adapter data

* Convert onReply to use the adapter data

* Convert NotificationViewData to Kotlin

* Re-implement the reblog functionality

- Move reblogging in to the view model
- Update the UI via the adapter's `snapshot()` and `notifyItemChanged()`
  methods

* Re-implement the favourite functionality

Same approach as reblog

* Re-implement the bookmark functionality

Same approach as reblog

* Add TODO re StatusActionListener interface

* Add TODO re event handling

* Re-implementing the voting functionality

* Re-implement viewing hidden content

- Hidden media
- Content behind a content warning

* Add a TODO re pinning

* Re-implement "Show more" / "Show less"

* Delete unused updateStatus() function

* Comment out the scroll listener for the moment

* Re-implement applying filters to notifications

Introduce `NotificationsRepository`, to provide access to the notifications
stream.

When changing the filters the flow is as follows:

- User clicks "Apply" in the fragment.

- Fragment calls `viewModel.accept()` with a `UiAction.ApplyFilter` (new
  class).

- View model maintains a private flow of incoming UI actions. The new action
  is emitted to that flow.

- In view model, `notificationFilter` waits for `.ApplyFilter` actions, and
  ensures the filter is saved, then emits it.

- In view model, `pagingDataFlow` waits for new items from
  `notificationsFilter` and fetches the notifications from the repository in
  response. The repository provides `Notification`, so the model maps them to
  `NotificationViewData.Concrete` for display by the adapter.

- In view model the UI state also waits for new items from
  `notificationsFilter` and emits a new `UiState` every time the filter is
  changed.

When opening the fragment for the first time:

- All of the above machinery, but `notificationFilter` also fetches the filter
  from the active account and emits that first. This triggers the first fetch
  and the first update of `uiState`.

Also:

- Add TODOs for functionality that is not implemented yet

- Delete a lot of dead code from NotificationsFragment

* Include important preference values in `uiState`

Listen to the flow of eventHub events, filtered to preference changes that
are relevant to the notification view.

When preferences change (or when the view model starts), fetch the current
values, and include them in `uiState`.

Remove preference handling from `NotificationsFragment`, and just use
the values from `uiState`.

Adjust how the `useAbsoluteTime` preference is handled. The previous code
loaded new content (via a diffutil) in to the adapter, which would trigger
a re-binding of the timestamp.

As the adapter content is immutable, the new code simply triggers a
re-binding of the views that are currently visible on screen.

* Update UI in response to different load states

Notifications can be loaded at the top and bottom of the timeline. Add a
new layout to show the progress of these loads, and any errors that can
occur.

Catch network errors in `NotificationsPagingSource` and convert to
`LoadState.Error`.

Add a header/footer to the notifications list to show the load state.

Collect the load state from the adapter, use this to drive the visibility
of different views.

* Save and restore the last read notification ID

Use this when fetching notifications, to centre the list around the
notification that was last read.

* Call notifyItemRangeChanged with the correct parameters

* Don't try and save list position if there are no items in the list

* Show/hide the "Nothing to see" view appropriately

* Update comments

* Handle the case where the notification key no longer exists

* Re-implement support for showMediaPreview and other settings

* Re-implement "hide FAB when scrolling" preference

* Delete dead code

* Delete Notifications Adapater and Placeholder types

* Remove NotificationViewData.Concrete subclass

Now there's no Placeholder, everything is a NotificationViewData.

* Improve how notification pages are loaded if the first notification is missing or filtered

* Re-implement clear notifications, show errors

* s/default/from/

* Add missing headers

* Don't process bookmarking via EventHub

- Initiating a bookmark is triggered by the fragment sending a
  StatusUiAction.Bookmark
- View model receives this, makes API call, waits for response, emits either
  a success or failure state
- Fragment collects success/failure states, updates the UI accordingly

* Don't process favourites via EventHub

* Don't process reblog via EventHub

* Don't process poll votes with EventHub

This removes EventHub from the fragment

* Respond to follow requests via the view model

* Docs and cleanup

* Typo and editing pass

* Minor edits for clarity

* Remove newline in diagram

* Reorder sequence diagram

* s/authorize/accept/

* s/pagingDataFlow/pagingData/

* Add brief KDoc

* Try and fetch a full first page of notifications

* Call the API method `notifications` again

* Log UI errors at the point of handling

* Remove unused variable

* Replace String.format() with interpolation

* Convert NotificationViewData to data class

* Rename copy() to make(), to avoid confusion with default copy() method

* Lint

* Update app/src/main/res/layout/simple_list_item_1.xml

* Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt

* Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt

* Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt

* Update app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt

* Initial NotificationsViewModel tests

* Add missing import

* More tests, some cleanup

* Comments, re-order some code

* Set StateRestorationPolicy.PREVENT_WHEN_EMPTY

* Mark clearNotifications() as "suspend"

* Catch exceptions from clearNotifications and emit

* Update TODOs with explanations

* Ensure initial fetch uses a null ID

* Stop/start collecting pagingData based on the lifecycle

* Don't hide the list while refreshing

* Refresh notifications on mutes and blocks

* Update tests now clearNotifications is a suspend fun

* Add "Refresh" menu to NotificationsFragment

* Use account.name over account.displayName

* Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>

* Mark layoutmanager as lateinit

* Mark layoutmanager as lateinit

* Refactor generating UI text

* Add Copyright header

* Correctly apply notification filters

* Show follow request header in notifications

* Wait for follow request actions to complete, so the reqeuest is sent

* Remove duplicate copyright header

* Revert copyright change in unmodified file

* Null check response body

* Move NotificationsFragment to component.notifications

* Use viewlifecycleowner.lifecyclescope

* Show notification filter as a dialog rather than a popup window

The popup window:

- Is inconsistent UI
- Requires a custom layout
- Didn't play nicely with viewbinding

* Refresh adapter on block/mute

* Scroll up slightly when new content is loaded

* Restore progressbar

* Lint

* Update app/src/main/res/layout/simple_list_item_1.xml

---------

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
Nik Clayton 1 year ago
parent
commit
4d401c7878
53 changed files with 4460 additions and 2262 deletions
  1. 2 0
      app/build.gradle
  2. 1 1
      app/src/main/java/com/keylesspalace/tusky/TabData.kt
  3. 42 5
      app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt
  4. 0 691
      app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
  5. 64 11
      app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt
  6. 2 2
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
  7. 8 2
      app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt
  8. 3 1
      app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
  9. 100 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt
  10. 681 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
  11. 38 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt
  12. 73 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt
  13. 209 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt
  14. 184 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt
  15. 74 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt
  16. 522 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
  17. 1 1
      app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt
  18. 385 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt
  19. 60 0
      app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt
  20. 3 1
      app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt
  21. 7 1
      app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt
  22. 6 4
      app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
  23. 3 1
      app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
  24. 5 0
      app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt
  25. 1 1
      app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
  26. 23 0
      app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
  27. 38 18
      app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt
  28. 0 1273
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  29. 17 7
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  30. 9 0
      app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt
  31. 104 2
      app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt
  32. 19 2
      app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
  33. 0 138
      app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java
  34. 43 0
      app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt
  35. 0 15
      app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt
  36. 18 1
      app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml
  37. 17 0
      app/src/main/res/layout/fragment_timeline_notifications.xml
  38. 45 0
      app/src/main/res/layout/item_notifications_load_state_footer_view.xml
  39. 0 19
      app/src/main/res/layout/notifications_filter.xml
  40. 27 0
      app/src/main/res/layout/simple_list_item_1.xml
  41. 17 0
      app/src/main/res/menu/fragment_notifications.xml
  42. 35 0
      app/src/main/res/values/strings.xml
  43. 83 65
      app/src/test/java/com/keylesspalace/tusky/FilterTest.kt
  44. 137 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt
  45. 65 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt
  46. 66 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt
  47. 144 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt
  48. 227 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt
  49. 102 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt
  50. 88 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt
  51. 43 0
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt
  52. 615 0
      doc/ViewModelInterface.md
  53. 4 0
      gradle/libs.versions.toml

+ 2 - 0
app/build.gradle

@@ -167,6 +167,8 @@ dependencies {
     testImplementation libs.androidx.core.testing
     testImplementation libs.kotlinx.coroutines.test
     testImplementation libs.androidx.work.testing
+    testImplementation libs.truth
+    testImplementation libs.turbine
 
     androidTestImplementation libs.espresso.core
     androidTestImplementation libs.androidx.room.testing

+ 1 - 1
app/src/main/java/com/keylesspalace/tusky/TabData.kt

@@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
 import androidx.annotation.StringRes
 import androidx.fragment.app.Fragment
 import com.keylesspalace.tusky.components.conversation.ConversationsFragment
+import com.keylesspalace.tusky.components.notifications.NotificationsFragment
 import com.keylesspalace.tusky.components.timeline.TimelineFragment
 import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
 import com.keylesspalace.tusky.components.trending.TrendingFragment
-import com.keylesspalace.tusky.fragment.NotificationsFragment
 import java.util.Objects
 
 /** this would be a good case for a sealed class, but that does not work nice with Room */

+ 42 - 5
app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt

@@ -21,18 +21,41 @@ import android.text.Spanned
 import android.text.style.StyleSpan
 import androidx.recyclerview.widget.RecyclerView
 import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
 import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
 import com.keylesspalace.tusky.entity.TimelineAccount
 import com.keylesspalace.tusky.interfaces.AccountActionListener
+import com.keylesspalace.tusky.util.StatusDisplayOptions
 import com.keylesspalace.tusky.util.emojify
 import com.keylesspalace.tusky.util.loadAvatar
 import com.keylesspalace.tusky.util.unicodeWrap
 import com.keylesspalace.tusky.util.visible
+import com.keylesspalace.tusky.viewdata.NotificationViewData
 
 class FollowRequestViewHolder(
     private val binding: ItemFollowRequestBinding,
+    private val accountActionListener: AccountActionListener,
     private val showHeader: Boolean
-) : RecyclerView.ViewHolder(binding.root) {
+) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
+
+    override fun bind(
+        viewData: NotificationViewData,
+        payloads: List<*>?,
+        statusDisplayOptions: StatusDisplayOptions
+    ) {
+        // Skip updates with payloads. That indicates a timestamp update, and
+        // this view does not have timestamps.
+        if (!payloads.isNullOrEmpty()) return
+
+        setupWithAccount(
+            viewData.account,
+            statusDisplayOptions.animateAvatars,
+            statusDisplayOptions.animateEmojis,
+            statusDisplayOptions.showBotOverlay
+        )
+
+        setupActionListener(accountActionListener, viewData.account.id)
+    }
 
     fun setupWithAccount(
         account: TimelineAccount,
@@ -41,18 +64,32 @@ class FollowRequestViewHolder(
         showBotOverlay: Boolean
     ) {
         val wrappedName = account.name.unicodeWrap()
-        val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
+        val emojifiedName: CharSequence = wrappedName.emojify(
+            account.emojis,
+            itemView,
+            animateEmojis
+        )
         binding.displayNameTextView.text = emojifiedName
         if (showHeader) {
-            val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
+            val wholeMessage: String = itemView.context.getString(
+                R.string.notification_follow_request_format,
+                wrappedName
+            )
             binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
-                setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+                setSpan(
+                    StyleSpan(Typeface.BOLD),
+                    0,
+                    wrappedName.length,
+                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+                )
             }.emojify(account.emojis, itemView, animateEmojis)
         }
         binding.notificationTextView.visible(showHeader)
         val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
         binding.usernameTextView.text = formattedUsername
-        val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
+        val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(
+            R.dimen.avatar_radius_48dp
+        )
         loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
         binding.avatarBadge.visible(showBotOverlay && account.bot)
     }

+ 0 - 691
app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java

@@ -1,691 +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 <http://www.gnu.org/licenses>. */
-
-package com.keylesspalace.tusky.adapter;
-
-import android.content.Context;
-import android.graphics.Color;
-import android.graphics.PorterDuff;
-import android.graphics.Typeface;
-import android.graphics.drawable.Drawable;
-import android.text.InputFilter;
-import android.text.SpannableStringBuilder;
-import android.text.Spanned;
-import android.text.TextUtils;
-import android.text.style.StyleSpan;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import androidx.annotation.ColorRes;
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.core.content.ContextCompat;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.bumptech.glide.Glide;
-import com.keylesspalace.tusky.R;
-import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
-import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
-import com.keylesspalace.tusky.entity.Emoji;
-import com.keylesspalace.tusky.entity.Notification;
-import com.keylesspalace.tusky.entity.Status;
-import com.keylesspalace.tusky.entity.TimelineAccount;
-import com.keylesspalace.tusky.interfaces.AccountActionListener;
-import com.keylesspalace.tusky.interfaces.LinkListener;
-import com.keylesspalace.tusky.interfaces.StatusActionListener;
-import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
-import com.keylesspalace.tusky.util.CardViewMode;
-import com.keylesspalace.tusky.util.CustomEmojiHelper;
-import com.keylesspalace.tusky.util.ImageLoadingHelper;
-import com.keylesspalace.tusky.util.LinkHelper;
-import com.keylesspalace.tusky.util.SmartLengthInputFilter;
-import com.keylesspalace.tusky.util.StatusDisplayOptions;
-import com.keylesspalace.tusky.util.StringUtils;
-import com.keylesspalace.tusky.util.TimestampUtils;
-import com.keylesspalace.tusky.viewdata.NotificationViewData;
-import com.keylesspalace.tusky.viewdata.StatusViewData;
-
-import java.util.Date;
-import java.util.List;
-
-import at.connyduck.sparkbutton.helpers.Utils;
-
-public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
-
-    public interface AdapterDataSource<T> {
-        int getItemCount();
-
-        T getItemAt(int pos);
-    }
-
-
-    private static final int VIEW_TYPE_STATUS = 0;
-    private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
-    private static final int VIEW_TYPE_FOLLOW = 2;
-    private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
-    private static final int VIEW_TYPE_PLACEHOLDER = 4;
-    private static final int VIEW_TYPE_REPORT = 5;
-    private static final int VIEW_TYPE_UNKNOWN = 6;
-
-    private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
-    private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
-
-    private final String accountId;
-    private StatusDisplayOptions statusDisplayOptions;
-    private final StatusActionListener statusListener;
-    private final NotificationActionListener notificationActionListener;
-    private final AccountActionListener accountActionListener;
-    private final AdapterDataSource<NotificationViewData> dataSource;
-    private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
-
-    public NotificationsAdapter(String accountId,
-                                AdapterDataSource<NotificationViewData> dataSource,
-                                StatusDisplayOptions statusDisplayOptions,
-                                StatusActionListener statusListener,
-                                NotificationActionListener notificationActionListener,
-                                AccountActionListener accountActionListener) {
-
-        this.accountId = accountId;
-        this.dataSource = dataSource;
-        this.statusDisplayOptions = statusDisplayOptions;
-        this.statusListener = statusListener;
-        this.notificationActionListener = notificationActionListener;
-        this.accountActionListener = accountActionListener;
-    }
-
-    @NonNull
-    @Override
-    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
-        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
-        switch (viewType) {
-            case VIEW_TYPE_STATUS: {
-                View view = inflater
-                        .inflate(R.layout.item_status, parent, false);
-                return new StatusViewHolder(view);
-            }
-            case VIEW_TYPE_STATUS_NOTIFICATION: {
-                View view = inflater
-                        .inflate(R.layout.item_status_notification, parent, false);
-                return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
-            }
-            case VIEW_TYPE_FOLLOW: {
-                View view = inflater
-                        .inflate(R.layout.item_follow, parent, false);
-                return new FollowViewHolder(view, statusDisplayOptions);
-            }
-            case VIEW_TYPE_FOLLOW_REQUEST: {
-                ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
-                return new FollowRequestViewHolder(binding, true);
-            }
-            case VIEW_TYPE_PLACEHOLDER: {
-                View view = inflater
-                        .inflate(R.layout.item_status_placeholder, parent, false);
-                return new PlaceholderViewHolder(view);
-            }
-            case VIEW_TYPE_REPORT: {
-                ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
-                return new ReportNotificationViewHolder(binding);
-            }
-            default:
-            case VIEW_TYPE_UNKNOWN: {
-                View view = new View(parent.getContext());
-                view.setLayoutParams(
-                        new ViewGroup.LayoutParams(
-                                ViewGroup.LayoutParams.MATCH_PARENT,
-                                Utils.dpToPx(parent.getContext(), 24)
-                        )
-                );
-                return new RecyclerView.ViewHolder(view) {
-                };
-            }
-        }
-    }
-
-    @Override
-    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
-        bindViewHolder(viewHolder, position, null);
-    }
-
-    @Override
-    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
-        bindViewHolder(viewHolder, position, payloads);
-    }
-
-    private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
-        Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
-        if (position < this.dataSource.getItemCount()) {
-            NotificationViewData notification = dataSource.getItemAt(position);
-            if (notification instanceof NotificationViewData.Placeholder) {
-                if (payloadForHolder == null) {
-                    NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
-                    PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
-                    holder.setup(statusListener, placeholder.isLoading());
-                }
-                return;
-            }
-            NotificationViewData.Concrete concreteNotification =
-                    (NotificationViewData.Concrete) notification;
-            switch (viewHolder.getItemViewType()) {
-                case VIEW_TYPE_STATUS: {
-                    StatusViewHolder holder = (StatusViewHolder) viewHolder;
-                    StatusViewData.Concrete status = concreteNotification.getStatusViewData();
-                    if (status == null) {
-                        /* in some very rare cases servers sends null status even though they should not,
-                         * we have to handle it somehow */
-                        holder.showStatusContent(false);
-                    } else {
-                        if (payloads == null) {
-                            holder.showStatusContent(true);
-                        }
-                        holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
-                    }
-                    if (concreteNotification.getType() == Notification.Type.POLL) {
-                        holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
-                    } else {
-                        holder.hideStatusInfo();
-                    }
-                    break;
-                }
-                case VIEW_TYPE_STATUS_NOTIFICATION: {
-                    StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
-                    StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
-                    if (payloadForHolder == null) {
-                        if (statusViewData == null) {
-                            /* in some very rare cases servers sends null status even though they should not,
-                             * we have to handle it somehow */
-                            holder.showNotificationContent(false);
-                        } else {
-                            holder.showNotificationContent(true);
-
-                            Status status = statusViewData.getActionable();
-                            holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
-                            holder.setUsername(status.getAccount().getUsername());
-                            holder.setCreatedAt(status.getCreatedAt());
-
-                            if (concreteNotification.getType() == Notification.Type.STATUS ||
-                                concreteNotification.getType() == Notification.Type.UPDATE) {
-                                holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
-                            } else {
-                                holder.setAvatars(status.getAccount().getAvatar(),
-                                        concreteNotification.getAccount().getAvatar());
-                            }
-                        }
-
-                        holder.setMessage(concreteNotification, statusListener);
-                        holder.setupButtons(notificationActionListener,
-                                concreteNotification.getAccount().getId(),
-                                concreteNotification.getId());
-                    } else {
-                        if (payloadForHolder instanceof List)
-                            for (Object item : (List<?>) payloadForHolder) {
-                                if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
-                                    holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
-                                }
-                            }
-                    }
-                    break;
-                }
-                case VIEW_TYPE_FOLLOW: {
-                    if (payloadForHolder == null) {
-                        FollowViewHolder holder = (FollowViewHolder) viewHolder;
-                        holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
-                        holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
-                    }
-                    break;
-                }
-                case VIEW_TYPE_FOLLOW_REQUEST: {
-                    if (payloadForHolder == null) {
-                        FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
-                        holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
-                        holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
-                    }
-                    break;
-                }
-                case VIEW_TYPE_REPORT: {
-                    if (payloadForHolder == null) {
-                        ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
-                        holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
-                        holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
-                    }
-                }
-                default:
-            }
-        }
-    }
-
-    @Override
-    public int getItemCount() {
-        return dataSource.getItemCount();
-    }
-
-    public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
-        this.statusDisplayOptions = statusDisplayOptions.copy(
-                statusDisplayOptions.animateAvatars(),
-                mediaPreviewEnabled,
-                statusDisplayOptions.useAbsoluteTime(),
-                statusDisplayOptions.showBotOverlay(),
-                statusDisplayOptions.useBlurhash(),
-                CardViewMode.NONE,
-                statusDisplayOptions.confirmReblogs(),
-                statusDisplayOptions.confirmFavourites(),
-                statusDisplayOptions.hideStats(),
-                statusDisplayOptions.animateEmojis()
-        );
-    }
-
-    public boolean isMediaPreviewEnabled() {
-        return this.statusDisplayOptions.mediaPreviewEnabled();
-    }
-
-    @Override
-    public int getItemViewType(int position) {
-        NotificationViewData notification = dataSource.getItemAt(position);
-        if (notification instanceof NotificationViewData.Concrete) {
-            NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
-            switch (concrete.getType()) {
-                case MENTION:
-                case POLL: {
-                    return VIEW_TYPE_STATUS;
-                }
-                case STATUS:
-                case FAVOURITE:
-                case REBLOG:
-                case UPDATE: {
-                    return VIEW_TYPE_STATUS_NOTIFICATION;
-                }
-                case FOLLOW:
-                case SIGN_UP: {
-                    return VIEW_TYPE_FOLLOW;
-                }
-                case FOLLOW_REQUEST: {
-                    return VIEW_TYPE_FOLLOW_REQUEST;
-                }
-                case REPORT: {
-                    return VIEW_TYPE_REPORT;
-                }
-                default: {
-                    return VIEW_TYPE_UNKNOWN;
-                }
-            }
-        } else if (notification instanceof NotificationViewData.Placeholder) {
-            return VIEW_TYPE_PLACEHOLDER;
-        } else {
-            throw new AssertionError("Unknown notification type");
-        }
-
-
-    }
-
-    public interface NotificationActionListener {
-        void onViewAccount(String id);
-
-        void onViewStatusForNotificationId(String notificationId);
-
-        void onViewReport(String reportId);
-
-        void onExpandedChange(boolean expanded, int position);
-
-        /**
-         * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
-         * status content is interacted with.
-         *
-         * @param isCollapsed Whether the status content is shown in a collapsed state or fully.
-         * @param position    The position of the status in the list.
-         */
-        void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
-    }
-
-    private static class FollowViewHolder extends RecyclerView.ViewHolder {
-        private final TextView message;
-        private final TextView usernameView;
-        private final TextView displayNameView;
-        private final ImageView avatar;
-        private final StatusDisplayOptions statusDisplayOptions;
-
-        FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
-            super(itemView);
-            message = itemView.findViewById(R.id.notification_text);
-            usernameView = itemView.findViewById(R.id.notification_username);
-            displayNameView = itemView.findViewById(R.id.notification_display_name);
-            avatar = itemView.findViewById(R.id.notification_avatar);
-            this.statusDisplayOptions = statusDisplayOptions;
-        }
-
-        void setMessage(TimelineAccount account, Boolean isSignUp) {
-            Context context = message.getContext();
-
-            String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
-            String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
-            String wholeMessage = String.format(format, wrappedDisplayName);
-            CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
-                    wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
-            );
-            message.setText(emojifiedMessage);
-
-            String username = context.getString(R.string.post_username_format, account.getUsername());
-            usernameView.setText(username);
-
-            CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
-                    wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
-            );
-
-            displayNameView.setText(emojifiedDisplayName);
-
-            int avatarRadius = avatar.getContext().getResources()
-                    .getDimensionPixelSize(R.dimen.avatar_radius_42dp);
-
-            ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
-                    statusDisplayOptions.animateAvatars());
-
-        }
-
-        void setupButtons(final NotificationActionListener listener, final String accountId) {
-            itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
-        }
-    }
-
-    private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
-            implements View.OnClickListener {
-        private final TextView message;
-        private final View statusNameBar;
-        private final TextView displayName;
-        private final TextView username;
-        private final TextView timestampInfo;
-        private final TextView statusContent;
-        private final ImageView statusAvatar;
-        private final ImageView notificationAvatar;
-        private final TextView contentWarningDescriptionTextView;
-        private final Button contentWarningButton;
-        private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
-        private final StatusDisplayOptions statusDisplayOptions;
-        private final AbsoluteTimeFormatter absoluteTimeFormatter;
-
-        private String accountId;
-        private String notificationId;
-        private NotificationActionListener notificationActionListener;
-        private StatusViewData.Concrete statusViewData;
-
-        private final int avatarRadius48dp;
-        private final int avatarRadius36dp;
-        private final int avatarRadius24dp;
-
-        StatusNotificationViewHolder(
-            View itemView,
-            StatusDisplayOptions statusDisplayOptions,
-            AbsoluteTimeFormatter absoluteTimeFormatter
-        ) {
-            super(itemView);
-            message = itemView.findViewById(R.id.notification_top_text);
-            statusNameBar = itemView.findViewById(R.id.status_name_bar);
-            displayName = itemView.findViewById(R.id.status_display_name);
-            username = itemView.findViewById(R.id.status_username);
-            timestampInfo = itemView.findViewById(R.id.status_meta_info);
-            statusContent = itemView.findViewById(R.id.notification_content);
-            statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
-            notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
-            contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
-            contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
-            contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
-            this.statusDisplayOptions = statusDisplayOptions;
-            this.absoluteTimeFormatter = absoluteTimeFormatter;
-
-            int darkerFilter = Color.rgb(123, 123, 123);
-            statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
-            notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
-
-            itemView.setOnClickListener(this);
-            message.setOnClickListener(this);
-            statusContent.setOnClickListener(this);
-
-            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);
-        }
-
-        private void showNotificationContent(boolean show) {
-            statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
-            contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
-            contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
-            statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
-            statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
-            notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
-        }
-
-        private void setDisplayName(String name, List<Emoji> emojis) {
-            CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
-            displayName.setText(emojifiedName);
-        }
-
-        private void setUsername(String name) {
-            Context context = username.getContext();
-            String format = context.getString(R.string.post_username_format);
-            String usernameText = String.format(format, name);
-            username.setText(usernameText);
-        }
-
-        protected void setCreatedAt(@Nullable Date createdAt) {
-            if (statusDisplayOptions.useAbsoluteTime()) {
-                timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
-            } else {
-                // This is the visible timestampInfo.
-                String readout;
-                /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
-                 * as 17 meters instead of minutes. */
-                CharSequence readoutAloud;
-                if (createdAt != null) {
-                    long then = createdAt.getTime();
-                    long now = new Date().getTime();
-                    readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
-                    readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
-                            android.text.format.DateUtils.SECOND_IN_MILLIS,
-                            android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
-                } else {
-                    // unknown minutes~
-                    readout = "?m";
-                    readoutAloud = "? minutes";
-                }
-                timestampInfo.setText(readout);
-                timestampInfo.setContentDescription(readoutAloud);
-            }
-        }
-
-        Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
-            Drawable icon = ContextCompat.getDrawable(context, drawable);
-            if (icon != null) {
-                icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
-            }
-            return icon;
-        }
-
-        void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
-            this.statusViewData = notificationViewData.getStatusViewData();
-
-            String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
-            Notification.Type type = notificationViewData.getType();
-
-            Context context = message.getContext();
-            String format;
-            Drawable icon;
-            switch (type) {
-                default:
-                case FAVOURITE: {
-                    icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
-                    format = context.getString(R.string.notification_favourite_format);
-                    break;
-                }
-                case REBLOG: {
-                    icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
-                    format = context.getString(R.string.notification_reblog_format);
-                    break;
-                }
-                case STATUS: {
-                    icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
-                    format = context.getString(R.string.notification_subscription_format);
-                    break;
-                }
-                case UPDATE: {
-                    icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
-                    format = context.getString(R.string.notification_update_format);
-                    break;
-                }
-            }
-            message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
-            String wholeMessage = String.format(format, displayName);
-            final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
-            int displayNameIndex = format.indexOf("%s");
-            str.setSpan(
-                    new StyleSpan(Typeface.BOLD),
-                    displayNameIndex,
-                    displayNameIndex + displayName.length(),
-                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
-            );
-            CharSequence emojifiedText = CustomEmojiHelper.emojify(
-                    str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
-            );
-            message.setText(emojifiedText);
-
-            if (statusViewData != null) {
-                boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
-                contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
-                contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
-                if (statusViewData.isExpanded()) {
-                    contentWarningButton.setText(R.string.post_content_warning_show_less);
-                } else {
-                    contentWarningButton.setText(R.string.post_content_warning_show_more);
-                }
-
-                contentWarningButton.setOnClickListener(view -> {
-                    if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
-                        notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
-                    }
-                    statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
-                });
-
-                setupContentAndSpoiler(listener);
-            }
-
-        }
-
-        void setupButtons(final NotificationActionListener listener, final String accountId,
-                          final String notificationId) {
-            this.notificationActionListener = listener;
-            this.accountId = accountId;
-            this.notificationId = notificationId;
-        }
-
-        void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
-            statusAvatar.setPaddingRelative(0, 0, 0, 0);
-
-            ImageLoadingHelper.loadAvatar(statusAvatarUrl,
-                    statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars());
-
-            if (statusDisplayOptions.showBotOverlay() && isBot) {
-                notificationAvatar.setVisibility(View.VISIBLE);
-                Glide.with(notificationAvatar)
-                        .load(R.drawable.bot_badge)
-                        .into(notificationAvatar);
-
-            } else {
-                notificationAvatar.setVisibility(View.GONE);
-            }
-        }
-
-        void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
-            int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
-            statusAvatar.setPaddingRelative(0, 0, padding, padding);
-
-            ImageLoadingHelper.loadAvatar(statusAvatarUrl,
-                    statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars());
-
-            notificationAvatar.setVisibility(View.VISIBLE);
-            ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
-                    avatarRadius24dp, statusDisplayOptions.animateAvatars());
-        }
-
-        @Override
-        public void onClick(View v) {
-            switch (v.getId()) {
-                case R.id.notification_container:
-                case R.id.notification_content:
-                    if (notificationActionListener != null)
-                        notificationActionListener.onViewStatusForNotificationId(notificationId);
-                    break;
-                case R.id.notification_top_text:
-                    if (notificationActionListener != null)
-                        notificationActionListener.onViewAccount(accountId);
-                    break;
-            }
-        }
-
-        private void setupContentAndSpoiler(final LinkListener listener) {
-
-            boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
-            boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
-            if (!shouldShowContentIfSpoiler && hasSpoiler) {
-                statusContent.setVisibility(View.GONE);
-            } else {
-                statusContent.setVisibility(View.VISIBLE);
-            }
-
-            Spanned content = statusViewData.getContent();
-            List<Emoji> emojis = statusViewData.getActionable().getEmojis();
-
-            if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
-                contentCollapseButton.setOnClickListener(view -> {
-                    int position = getBindingAdapterPosition();
-                    if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
-                        notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
-                    }
-                });
-
-                contentCollapseButton.setVisibility(View.VISIBLE);
-                if (statusViewData.isCollapsed()) {
-                    contentCollapseButton.setText(R.string.post_content_warning_show_more);
-                    statusContent.setFilters(COLLAPSE_INPUT_FILTER);
-                } else {
-                    contentCollapseButton.setText(R.string.post_content_warning_show_less);
-                    statusContent.setFilters(NO_INPUT_FILTER);
-                }
-            } else {
-                contentCollapseButton.setVisibility(View.GONE);
-                statusContent.setFilters(NO_INPUT_FILTER);
-            }
-
-            CharSequence emojifiedText = CustomEmojiHelper.emojify(
-                    content, emojis, statusContent, statusDisplayOptions.animateEmojis()
-            );
-            LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
-
-            CharSequence emojifiedContentWarning;
-            if (statusViewData.getSpoilerText() != null) {
-                emojifiedContentWarning = CustomEmojiHelper.emojify(
-                        statusViewData.getSpoilerText(),
-                        statusViewData.getActionable().getEmojis(),
-                        contentWarningDescriptionTextView,
-                        statusDisplayOptions.animateEmojis()
-                );
-            } else {
-                emojifiedContentWarning = "";
-            }
-            contentWarningDescriptionTextView.setText(emojifiedContentWarning);
-        }
-
-    }
-}

+ 64 - 11
app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt

@@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat
 import androidx.recyclerview.widget.RecyclerView
 import at.connyduck.sparkbutton.helpers.Utils
 import com.keylesspalace.tusky.R
-import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
+import com.keylesspalace.tusky.components.notifications.NotificationActionListener
+import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
 import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
 import com.keylesspalace.tusky.entity.Report
 import com.keylesspalace.tusky.entity.TimelineAccount
+import com.keylesspalace.tusky.util.StatusDisplayOptions
 import com.keylesspalace.tusky.util.emojify
 import com.keylesspalace.tusky.util.getRelativeTimeSpanString
 import com.keylesspalace.tusky.util.loadAvatar
 import com.keylesspalace.tusky.util.unicodeWrap
+import com.keylesspalace.tusky.viewdata.NotificationViewData
 import java.util.Date
 
 class ReportNotificationViewHolder(
     private val binding: ItemReportNotificationBinding,
-) : RecyclerView.ViewHolder(binding.root) {
+    private val notificationActionListener: NotificationActionListener,
+) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
 
-    fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
-        val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
-        val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
-        val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
+    override fun bind(
+        viewData: NotificationViewData,
+        payloads: List<*>?,
+        statusDisplayOptions: StatusDisplayOptions
+    ) {
+        // Skip updates with payloads. That indicates a timestamp update, and
+        // this view does not have timestamps.
+        if (!payloads.isNullOrEmpty()) return
+
+        setupWithReport(
+            viewData.account,
+            viewData.report!!,
+            statusDisplayOptions.animateAvatars,
+            statusDisplayOptions.animateEmojis
+        )
+        setupActionListener(
+            notificationActionListener,
+            viewData.report.targetAccount.id,
+            viewData.account.id,
+            viewData.report.id
+        )
+    }
+
+    private fun setupWithReport(
+        reporter: TimelineAccount,
+        report: Report,
+        animateAvatar: Boolean,
+        animateEmojis: Boolean
+    ) {
+        val reporterName = reporter.name.unicodeWrap().emojify(
+            reporter.emojis,
+            binding.root,
+            animateEmojis
+        )
+        val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
+            report.targetAccount.emojis,
+            itemView,
+            animateEmojis
+        )
+        val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
 
         binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
-        binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
-        binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0)
+        binding.notificationTopText.text = itemView.context.getString(
+            R.string.notification_header_report_format,
+            reporterName,
+            reporteeName
+        )
+        binding.notificationSummary.text = itemView.context.getString(
+            R.string.notification_summary_report_format,
+            getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
+            report.status_ids?.size ?: 0
+        )
         binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
 
         // Fancy avatar inset
@@ -52,17 +100,22 @@ class ReportNotificationViewHolder(
             report.targetAccount.avatar,
             binding.notificationReporteeAvatar,
             itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
-            animateAvatar,
+            animateAvatar
         )
         loadAvatar(
             reporter.avatar,
             binding.notificationReporterAvatar,
             itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
-            animateAvatar,
+            animateAvatar
         )
     }
 
-    fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
+    private fun setupActionListener(
+        listener: NotificationActionListener,
+        reporteeId: String,
+        reporterId: String,
+        reportId: String
+    ) {
         binding.notificationReporteeAvatar.setOnClickListener {
             val position = bindingAdapterPosition
             if (position != RecyclerView.NO_POSITION) {

+ 2 - 2
app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java

@@ -93,7 +93,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
     }
 
     // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
-    void setPollInfo(final boolean ownPoll) {
+    protected void setPollInfo(final boolean ownPoll) {
         statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
         statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
         statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
@@ -101,7 +101,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
         statusInfo.setVisibility(View.VISIBLE);
     }
 
-    void hideStatusInfo() {
+    protected void hideStatusInfo() {
         statusInfo.setVisibility(View.GONE);
     }
 

+ 8 - 2
app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt

@@ -35,8 +35,14 @@ class FollowRequestsAdapter(
 ) {
 
     override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
-        val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        return FollowRequestViewHolder(binding, false)
+        val binding = ItemFollowRequestBinding.inflate(
+            LayoutInflater.from(parent.context), parent, false
+        )
+        return FollowRequestViewHolder(
+            binding,
+            accountActionListener,
+            showHeader = false
+        )
     }
 
     override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {

+ 3 - 1
app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt

@@ -110,7 +110,9 @@ class ConversationsFragment :
             confirmReblogs = preferences.getBoolean("confirmReblogs", true),
             confirmFavourites = preferences.getBoolean("confirmFavourites", false),
             hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
-            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+            showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
+            openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
         )
 
         adapter = ConversationAdapter(statusDisplayOptions, this)

+ 100 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt

@@ -0,0 +1,100 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import androidx.recyclerview.widget.RecyclerView
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemFollowBinding
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.TimelineAccount
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.util.emojify
+import com.keylesspalace.tusky.util.loadAvatar
+import com.keylesspalace.tusky.util.unicodeWrap
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+
+class FollowViewHolder(
+    private val binding: ItemFollowBinding,
+    private val notificationActionListener: NotificationActionListener,
+) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
+    private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize(
+        R.dimen.avatar_radius_42dp
+    )
+
+    override fun bind(
+        viewData: NotificationViewData,
+        payloads: List<*>?,
+        statusDisplayOptions: StatusDisplayOptions
+    ) {
+        // Skip updates with payloads. That indicates a timestamp update, and
+        // this view does not have timestamps.
+        if (!payloads.isNullOrEmpty()) return
+
+        setMessage(
+            viewData.account,
+            viewData.type === Notification.Type.SIGN_UP,
+            statusDisplayOptions.animateAvatars,
+            statusDisplayOptions.animateEmojis
+        )
+        setupButtons(notificationActionListener, viewData.account.id)
+    }
+
+    private fun setMessage(
+        account: TimelineAccount,
+        isSignUp: Boolean,
+        animateAvatars: Boolean,
+        animateEmojis: Boolean
+    ) {
+        val context = binding.notificationText.context
+        val format =
+            context.getString(
+                if (isSignUp) {
+                    R.string.notification_sign_up_format
+                } else {
+                    R.string.notification_follow_format
+                }
+            )
+        val wrappedDisplayName = account.name.unicodeWrap()
+        val wholeMessage = String.format(format, wrappedDisplayName)
+        val emojifiedMessage =
+            wholeMessage.emojify(
+                account.emojis,
+                binding.notificationText,
+                animateEmojis
+            )
+        binding.notificationText.text = emojifiedMessage
+        val username = context.getString(R.string.post_username_format, account.username)
+        binding.notificationUsername.text = username
+        val emojifiedDisplayName = wrappedDisplayName.emojify(
+            account.emojis,
+            binding.notificationUsername,
+            animateEmojis
+        )
+        binding.notificationDisplayName.text = emojifiedDisplayName
+        loadAvatar(
+            account.avatar,
+            binding.notificationAvatar,
+            avatarRadius42dp,
+            animateAvatars
+        )
+    }
+
+    private fun setupButtons(listener: NotificationActionListener, accountId: String) {
+        binding.root.setOnClickListener { listener.onViewAccount(accountId) }
+    }
+}

+ 681 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt

@@ -0,0 +1,681 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.MenuProvider
+import androidx.core.view.isVisible
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.paging.LoadState
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.SimpleItemAnimator
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
+import at.connyduck.sparkbutton.helpers.Utils
+import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.snackbar.Snackbar
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
+import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
+import com.keylesspalace.tusky.di.Injectable
+import com.keylesspalace.tusky.di.ViewModelFactory
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.Status
+import com.keylesspalace.tusky.fragment.SFragment
+import com.keylesspalace.tusky.interfaces.AccountActionListener
+import com.keylesspalace.tusky.interfaces.ActionButtonActivity
+import com.keylesspalace.tusky.interfaces.ReselectableFragment
+import com.keylesspalace.tusky.interfaces.StatusActionListener
+import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
+import com.keylesspalace.tusky.util.openLink
+import com.keylesspalace.tusky.util.viewBinding
+import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+import com.mikepenz.iconics.IconicsDrawable
+import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
+import com.mikepenz.iconics.utils.colorInt
+import com.mikepenz.iconics.utils.sizeDp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import java.io.IOException
+import javax.inject.Inject
+
+class NotificationsFragment :
+    SFragment(),
+    StatusActionListener,
+    NotificationActionListener,
+    AccountActionListener,
+    OnRefreshListener,
+    MenuProvider,
+    Injectable,
+    ReselectableFragment {
+
+    @Inject
+    lateinit var viewModelFactory: ViewModelFactory
+
+    private val viewModel: NotificationsViewModel by viewModels { viewModelFactory }
+
+    private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
+
+    private lateinit var adapter: NotificationsPagingAdapter
+
+    private lateinit var layoutManager: LinearLayoutManager
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        adapter = NotificationsPagingAdapter(
+            notificationDiffCallback,
+            accountId = accountManager.activeAccount!!.accountId,
+            statusActionListener = this,
+            notificationActionListener = this,
+            accountActionListener = this,
+            statusDisplayOptions = viewModel.statusDisplayOptions.value
+        )
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
+    }
+
+    private fun updateFilterVisibility(showFilter: Boolean) {
+        val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
+        if (showFilter) {
+            binding.appBarOptions.setExpanded(true, false)
+            binding.appBarOptions.visibility = View.VISIBLE
+            // Set content behaviour to hide filter on scroll
+            params.behavior = ScrollingViewBehavior()
+        } else {
+            binding.appBarOptions.setExpanded(false, false)
+            binding.appBarOptions.visibility = View.GONE
+            // Clear behaviour to hide app bar
+            params.behavior = null
+        }
+    }
+
+    private fun confirmClearNotifications() {
+        AlertDialog.Builder(requireContext())
+            .setMessage(R.string.notification_clear_text)
+            .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() }
+            .setNegativeButton(android.R.string.cancel, null)
+            .show()
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+
+        // Setup the SwipeRefreshLayout.
+        binding.swipeRefreshLayout.setOnRefreshListener(this)
+        binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
+
+        // Setup the RecyclerView.
+        binding.recyclerView.setHasFixedSize(true)
+        layoutManager = LinearLayoutManager(context)
+        binding.recyclerView.layoutManager = layoutManager
+        binding.recyclerView.setAccessibilityDelegateCompat(
+            ListStatusAccessibilityDelegate(
+                binding.recyclerView,
+                this
+            ) { pos: Int ->
+                val notification = adapter.snapshot()[pos]
+                // We support replies only for now
+                if (notification is NotificationViewData) {
+                    notification.statusViewData
+                } else {
+                    null
+                }
+            }
+        )
+        binding.recyclerView.addItemDecoration(
+            DividerItemDecoration(
+                context,
+                DividerItemDecoration.VERTICAL
+            )
+        )
+
+        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+            val actionButton = (activity as ActionButtonActivity).actionButton
+
+            override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
+                actionButton?.let { fab ->
+                    if (!viewModel.uiState.value.showFabWhileScrolling) {
+                        if (dy > 0 && fab.isShown) {
+                            fab.hide() // Hide when scrolling down
+                        } else if (dy < 0 && !fab.isShown) {
+                            fab.show() // Show when scrolling up
+                        }
+                    } else if (!fab.isShown) {
+                        fab.show()
+                    }
+                }
+            }
+        })
+
+        binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
+            header = NotificationsLoadStateAdapter { adapter.retry() },
+            footer = NotificationsLoadStateAdapter { adapter.retry() }
+        )
+
+        binding.buttonClear.setOnClickListener { confirmClearNotifications() }
+        binding.buttonFilter.setOnClickListener { showFilterDialog() }
+        (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
+            false
+
+        // Signal the user that a refresh has loaded new items above their current position
+        // by scrolling up slightly to disclose the new content
+        adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
+            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
+                if (positionStart == 0 && adapter.itemCount != itemCount) {
+                    binding.recyclerView.post {
+                        binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
+                    }
+                }
+            }
+        })
+
+        /**
+         * Collect this flow to notify the adapter that the timestamps of the visible items have
+         * changed
+         */
+        val updateTimestampFlow = flow {
+            while (true) { delay(60000); emit(Unit) }
+        }.onEach {
+            layoutManager.findFirstVisibleItemPosition().let { first ->
+                first == RecyclerView.NO_POSITION && return@let
+                val count = layoutManager.findLastVisibleItemPosition() - first
+                adapter.notifyItemRangeChanged(
+                    first,
+                    count,
+                    listOf(StatusBaseViewHolder.Key.KEY_CREATED)
+                )
+            }
+        }
+
+        viewLifecycleOwner.lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch {
+                    viewModel.pagingData.collectLatest { pagingData ->
+                        Log.d(TAG, "Submitting data to adapter")
+                        adapter.submitData(pagingData)
+                    }
+                }
+
+                // Show errors from the view model as snack bars.
+                //
+                // Errors are shown:
+                // - Indefinitely, so the user has a chance to read and understand
+                //   the message
+                // - With a max of 5 text lines, to allow space for longer errors.
+                //   E.g., on a typical device, an error message like "Bookmarking
+                //   post failed: Unable to resolve host 'mastodon.social': No
+                //   address associated with hostname" is 3 lines.
+                // - With a "Retry" option if the error included a UiAction to retry.
+                launch {
+                    viewModel.uiError.collect { error ->
+                        Log.d(TAG, error.toString())
+                        val message = getString(
+                            error.message,
+                            error.exception.localizedMessage
+                                ?: getString(R.string.ui_error_unknown)
+                        )
+                        val snackbar = Snackbar.make(
+                            // Without this the FAB will not move out of the way
+                            (activity as ActionButtonActivity).actionButton ?: binding.root,
+                            message,
+                            Snackbar.LENGTH_INDEFINITE
+                        ).setTextMaxLines(5)
+                        error.action?.let { action ->
+                            snackbar.setAction(R.string.action_retry) {
+                                viewModel.accept(action)
+                            }
+                        }
+                        snackbar.show()
+
+                        // The status view has pre-emptively updated its state to show
+                        // that the action succeeded. Since it hasn't, re-bind the view
+                        // to show the correct data.
+                        error.action?.let { action ->
+                            action is StatusAction || return@let
+
+                            val position = adapter.snapshot().indexOfFirst {
+                                it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id
+                            }
+                            if (position != RecyclerView.NO_POSITION) {
+                                adapter.notifyItemChanged(position)
+                            }
+                        }
+                    }
+                }
+
+                // Show successful notification action as brief snackbars, so the
+                // user is clear the action has happened.
+                launch {
+                    viewModel.uiSuccess
+                        .filterIsInstance<NotificationActionSuccess>()
+                        .collect {
+                            Snackbar.make(
+                                (activity as ActionButtonActivity).actionButton ?: binding.root,
+                                getString(it.msg),
+                                Snackbar.LENGTH_SHORT
+                            ).show()
+
+                            when (it) {
+                                // The follow request is no longer valid, refresh the adapter to
+                                // remove it.
+                                is NotificationActionSuccess.AcceptFollowRequest,
+                                is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh()
+                            }
+                        }
+                }
+
+                // Update adapter data when status actions are successful, and re-bind to update
+                // the UI.
+                launch {
+                    viewModel.uiSuccess
+                        .filterIsInstance<StatusActionSuccess>()
+                        .collect {
+                            val indexedViewData = adapter.snapshot()
+                                .withIndex()
+                                .firstOrNull { notificationViewData ->
+                                    notificationViewData.value?.statusViewData?.status?.id ==
+                                        it.action.statusViewData.id
+                                } ?: return@collect
+
+                            val statusViewData =
+                                indexedViewData.value?.statusViewData ?: return@collect
+
+                            val status = when (it) {
+                                is StatusActionSuccess.Bookmark ->
+                                    statusViewData.status.copy(bookmarked = it.action.state)
+                                is StatusActionSuccess.Favourite ->
+                                    statusViewData.status.copy(favourited = it.action.state)
+                                is StatusActionSuccess.Reblog ->
+                                    statusViewData.status.copy(reblogged = it.action.state)
+                                is StatusActionSuccess.VoteInPoll ->
+                                    statusViewData.status.copy(
+                                        poll = it.action.poll.votedCopy(it.action.choices)
+                                    )
+                            }
+                            indexedViewData.value?.statusViewData = statusViewData.copy(
+                                status = status
+                            )
+
+                            adapter.notifyItemChanged(indexedViewData.index)
+                        }
+                }
+
+                // Refresh adapter on mutes and blocks
+                launch {
+                    viewModel.uiSuccess.collectLatest {
+                        when (it) {
+                            is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation ->
+                                adapter.refresh()
+                            else -> { /* nothing to do */
+                            }
+                        }
+                    }
+                }
+
+                // Update filter option visibility from uiState
+                launch {
+                    viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) }
+                }
+
+                // Update status display from statusDisplayOptions. If the new options request
+                // relative time display collect the flow to periodically re-bind the UI.
+                launch {
+                    viewModel.statusDisplayOptions
+                        .collectLatest {
+                            adapter.statusDisplayOptions = it
+                            layoutManager.findFirstVisibleItemPosition().let { first ->
+                                first == RecyclerView.NO_POSITION && return@let
+                                val count = layoutManager.findLastVisibleItemPosition() - first
+                                adapter.notifyItemRangeChanged(
+                                    first,
+                                    count,
+                                    null
+                                )
+                            }
+
+                            if (!it.useAbsoluteTime) {
+                                updateTimestampFlow.collect()
+                            }
+                        }
+                }
+
+                // Update the UI from the loadState
+                adapter.loadStateFlow
+                    .distinctUntilChangedBy { it.refresh }
+                    .collect { loadState ->
+                        binding.recyclerView.isVisible = true
+                        binding.progressBar.isVisible = loadState.refresh is LoadState.Loading &&
+                            !binding.swipeRefreshLayout.isRefreshing
+                        binding.swipeRefreshLayout.isRefreshing =
+                            loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible
+
+                        binding.statusView.isVisible = false
+                        if (loadState.refresh is LoadState.NotLoading) {
+                            if (adapter.itemCount == 0) {
+                                binding.statusView.setup(
+                                    R.drawable.elephant_friend_empty,
+                                    R.string.message_empty
+                                )
+                                binding.recyclerView.isVisible = false
+                                binding.statusView.isVisible = true
+                            } else {
+                                binding.statusView.isVisible = false
+                            }
+                        }
+
+                        if (loadState.refresh is LoadState.Error) {
+                            when ((loadState.refresh as LoadState.Error).error) {
+                                is IOException -> {
+                                    binding.statusView.setup(
+                                        R.drawable.elephant_offline,
+                                        R.string.error_network
+                                    ) { adapter.retry() }
+                                }
+                                else -> {
+                                    binding.statusView.setup(
+                                        R.drawable.elephant_error,
+                                        R.string.error_generic
+                                    ) { adapter.retry() }
+                                }
+                            }
+                            binding.recyclerView.isVisible = false
+                            binding.statusView.isVisible = true
+                        }
+                    }
+            }
+        }
+    }
+
+    override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+        menuInflater.inflate(R.menu.fragment_notifications, menu)
+        menu.findItem(R.id.action_refresh)?.apply {
+            icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
+                sizeDp = 20
+                colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
+            }
+        }
+    }
+
+    override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+        return when (menuItem.itemId) {
+            R.id.action_refresh -> {
+                binding.swipeRefreshLayout.isRefreshing = true
+                onRefresh()
+                true
+            }
+            else -> false
+        }
+    }
+
+    override fun onRefresh() {
+        binding.progressBar.isVisible = false
+        adapter.refresh()
+    }
+
+    override fun onPause() {
+        super.onPause()
+
+        // Save the ID of the first notification visible in the list
+        val position = layoutManager.findFirstVisibleItemPosition()
+        if (position >= 0) {
+            adapter.snapshot()[position]?.id?.let { id ->
+                viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
+            }
+        }
+    }
+
+    override fun onResume() {
+        super.onResume()
+        NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager)
+    }
+
+    override fun onReply(position: Int) {
+        val status = adapter.peek(position)?.statusViewData?.status ?: return
+        super.reply(status)
+    }
+
+    override fun onReblog(reblog: Boolean, position: Int) {
+        val statusViewData = adapter.peek(position)?.statusViewData ?: return
+        viewModel.accept(StatusAction.Reblog(reblog, statusViewData))
+    }
+
+    override fun onFavourite(favourite: Boolean, position: Int) {
+        val statusViewData = adapter.peek(position)?.statusViewData ?: return
+        viewModel.accept(StatusAction.Favourite(favourite, statusViewData))
+    }
+
+    override fun onBookmark(bookmark: Boolean, position: Int) {
+        val statusViewData = adapter.peek(position)?.statusViewData ?: return
+        viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData))
+    }
+
+    override fun onVoteInPoll(position: Int, choices: List<Int>) {
+        val statusViewData = adapter.peek(position)?.statusViewData ?: return
+        val poll = statusViewData.status.poll ?: return
+        viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData))
+    }
+
+    override fun onMore(view: View, position: Int) {
+        val status = adapter.peek(position)?.statusViewData?.status ?: return
+        super.more(status, view, position)
+    }
+
+    override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
+        val status = adapter.peek(position)?.statusViewData?.status ?: return
+        super.viewMedia(attachmentIndex, list(status), view)
+    }
+
+    override fun onViewThread(position: Int) {
+        val status = adapter.peek(position)?.statusViewData?.status ?: return
+        super.viewThread(status.actionableId, status.actionableStatus.url)
+    }
+
+    override fun onOpenReblog(position: Int) {
+        val account = adapter.peek(position)?.account!!
+        onViewAccount(account.id)
+    }
+
+    override fun onExpandedChange(expanded: Boolean, position: Int) {
+        val notificationViewData = adapter.snapshot()[position] ?: return
+        notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
+            isExpanded = expanded
+        )
+        adapter.notifyItemChanged(position)
+    }
+
+    override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
+        val notificationViewData = adapter.snapshot()[position] ?: return
+        notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
+            isShowingContent = isShowing
+        )
+        adapter.notifyItemChanged(position)
+    }
+
+    override fun onLoadMore(position: Int) {
+        // Empty -- this fragment doesn't show placeholders
+    }
+
+    override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
+        val notificationViewData = adapter.snapshot()[position] ?: return
+        notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
+            isCollapsed = isCollapsed
+        )
+        adapter.notifyItemChanged(position)
+    }
+
+    override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) {
+        onContentCollapsedChange(isCollapsed, position)
+    }
+
+    private fun clearNotifications() {
+        binding.swipeRefreshLayout.isRefreshing = false
+        binding.progressBar.isVisible = false
+        viewModel.accept(FallibleUiAction.ClearNotifications)
+    }
+
+    private fun showFilterDialog() {
+        FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter ->
+            if (viewModel.uiState.value.activeFilter != filter) {
+                viewModel.accept(InfallibleUiAction.ApplyFilter(filter))
+            }
+        }
+            .show(parentFragmentManager, "dialogFilter")
+    }
+
+    override fun onViewTag(tag: String) {
+        super.viewTag(tag)
+    }
+
+    override fun onViewAccount(id: String) {
+        super.viewAccount(id)
+    }
+
+    override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
+        adapter.refresh()
+    }
+
+    override fun onBlock(block: Boolean, id: String, position: Int) {
+        adapter.refresh()
+    }
+
+    override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
+        if (accept) {
+            viewModel.accept(NotificationAction.AcceptFollowRequest(accountId))
+        } else {
+            viewModel.accept(NotificationAction.RejectFollowRequest(accountId))
+        }
+    }
+
+    override fun onViewThreadForStatus(status: Status) {
+        super.viewThread(status.actionableId, status.actionableStatus.url)
+    }
+
+    override fun onViewReport(reportId: String) {
+        requireContext().openLink(
+            "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
+        )
+    }
+
+    public override fun removeItem(position: Int) {
+        // Empty -- this fragment doesn't remove items
+    }
+
+    override fun onReselect() {
+        if (isAdded) {
+            binding.appBarOptions.setExpanded(true, false)
+            layoutManager.scrollToPosition(0)
+        }
+    }
+
+    companion object {
+        private const val TAG = "NotificationF"
+        fun newInstance() = NotificationsFragment()
+
+        private val notificationDiffCallback: DiffUtil.ItemCallback<NotificationViewData> =
+            object : DiffUtil.ItemCallback<NotificationViewData>() {
+                override fun areItemsTheSame(
+                    oldItem: NotificationViewData,
+                    newItem: NotificationViewData
+                ): Boolean {
+                    return oldItem.id == newItem.id
+                }
+
+                override fun areContentsTheSame(
+                    oldItem: NotificationViewData,
+                    newItem: NotificationViewData
+                ): Boolean {
+                    return false
+                }
+
+                override fun getChangePayload(
+                    oldItem: NotificationViewData,
+                    newItem: NotificationViewData
+                ): Any? {
+                    return if (oldItem == newItem) {
+                        //  If items are equal - update timestamp only
+                        listOf(StatusBaseViewHolder.Key.KEY_CREATED)
+                    } else {
+                        // If items are different - update a whole view holder
+                        null
+                    }
+                }
+            }
+    }
+}
+
+class FilterDialogFragment(
+    private val activeFilter: Set<Notification.Type>,
+    private val listener: ((filter: Set<Notification.Type>) -> Unit)
+) : DialogFragment() {
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val context = requireContext()
+
+        val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray()
+        val checkedItems = Notification.Type.visibleTypes.map {
+            !activeFilter.contains(it)
+        }.toBooleanArray()
+
+        val builder = AlertDialog.Builder(context)
+            .setTitle(R.string.notifications_apply_filter)
+            .setMultiChoiceItems(items, checkedItems) { _, which, isChecked ->
+                checkedItems[which] = isChecked
+            }
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                val excludes: MutableSet<Notification.Type> = HashSet()
+                for (i in Notification.Type.visibleTypes.indices) {
+                    if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i])
+                }
+                listener(excludes)
+            }
+            .setNegativeButton(android.R.string.cancel) { _, _ -> }
+        return builder.create()
+    }
+}

+ 38 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.view.ViewGroup
+import androidx.paging.LoadState
+import androidx.paging.LoadStateAdapter
+
+/** Show load state and retry options when loading notifications */
+class NotificationsLoadStateAdapter(
+    private val retry: () -> Unit
+) : LoadStateAdapter<NotificationsLoadStateViewHolder>() {
+    override fun onCreateViewHolder(
+        parent: ViewGroup,
+        loadState: LoadState
+    ): NotificationsLoadStateViewHolder {
+        return NotificationsLoadStateViewHolder.create(parent, retry)
+    }
+
+    override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
+        holder.bind(loadState)
+    }
+}

+ 73 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.paging.LoadState
+import androidx.recyclerview.widget.RecyclerView
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding
+import java.net.SocketTimeoutException
+
+/**
+ * Display the header/footer loading state to the user.
+ *
+ * Either:
+ *
+ * 1. A page is being loaded, display a progress view, or
+ * 2. An error occurred, display an error message with a "retry" button
+ *
+ * @param retry function to invoke if the user clicks the "retry" button
+ */
+class NotificationsLoadStateViewHolder(
+    private val binding: ItemNotificationsLoadStateFooterViewBinding,
+    retry: () -> Unit
+) : RecyclerView.ViewHolder(binding.root) {
+    init {
+        binding.retryButton.setOnClickListener { retry.invoke() }
+    }
+
+    fun bind(loadState: LoadState) {
+        if (loadState is LoadState.Error) {
+            val ctx = binding.root.context
+            binding.errorMsg.text = when (loadState.error) {
+                is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception)
+                // Other exceptions to consider:
+                // - UnknownHostException, default text is:
+                //   Unable to resolve "%s": No address associated with hostname
+                else -> loadState.error.localizedMessage
+            }
+        }
+        binding.progressBar.isVisible = loadState is LoadState.Loading
+        binding.retryButton.isVisible = loadState is LoadState.Error
+        binding.errorMsg.isVisible = loadState is LoadState.Error
+    }
+
+    companion object {
+        fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder {
+            val binding = ItemNotificationsLoadStateFooterViewBinding.inflate(
+                LayoutInflater.from(parent.context),
+                parent,
+                false
+            )
+            return NotificationsLoadStateViewHolder(binding, retry)
+        }
+    }
+}

+ 209 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt

@@ -0,0 +1,209 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.paging.PagingDataAdapter
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
+import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder
+import com.keylesspalace.tusky.databinding.ItemFollowBinding
+import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
+import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
+import com.keylesspalace.tusky.databinding.ItemStatusBinding
+import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
+import com.keylesspalace.tusky.databinding.SimpleListItem1Binding
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.Status
+import com.keylesspalace.tusky.interfaces.AccountActionListener
+import com.keylesspalace.tusky.interfaces.StatusActionListener
+import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+
+/** How to present the notification in the UI */
+enum class NotificationViewKind {
+    /** View as the original status */
+    STATUS,
+
+    /** View as the original status, with the interaction type above */
+    NOTIFICATION,
+    FOLLOW,
+    FOLLOW_REQUEST,
+    REPORT,
+    UNKNOWN;
+
+    companion object {
+        fun from(kind: Notification.Type?): NotificationViewKind {
+            return when (kind) {
+                Notification.Type.MENTION,
+                Notification.Type.POLL,
+                Notification.Type.UNKNOWN -> STATUS
+                Notification.Type.FAVOURITE,
+                Notification.Type.REBLOG,
+                Notification.Type.STATUS,
+                Notification.Type.UPDATE -> NOTIFICATION
+                Notification.Type.FOLLOW,
+                Notification.Type.SIGN_UP -> FOLLOW
+                Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST
+                Notification.Type.REPORT -> REPORT
+                null -> UNKNOWN
+            }
+        }
+    }
+}
+
+interface NotificationActionListener {
+    fun onViewAccount(id: String)
+    fun onViewThreadForStatus(status: Status)
+    fun onViewReport(reportId: String)
+
+    /**
+     * Called when the status has a content warning and the visibility of the content behind
+     * the warning is being changed.
+     *
+     * @param expanded the desired state of the content behind the content warning
+     * @param position the adapter position of the view
+     *
+     */
+    fun onExpandedChange(expanded: Boolean, position: Int)
+
+    /**
+     * Called when the status [android.widget.ToggleButton] responsible for collapsing long
+     * status content is interacted with.
+     *
+     * @param isCollapsed Whether the status content is shown in a collapsed state or fully.
+     * @param position    The position of the status in the list.
+     */
+    fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int)
+}
+
+class NotificationsPagingAdapter(
+    diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
+    /** ID of the the account that notifications are being displayed for */
+    private val accountId: String,
+    private val statusActionListener: StatusActionListener,
+    private val notificationActionListener: NotificationActionListener,
+    private val accountActionListener: AccountActionListener,
+    var statusDisplayOptions: StatusDisplayOptions
+) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
+
+    private val absoluteTimeFormatter = AbsoluteTimeFormatter()
+
+    /** View holders in this adapter must implement this interface */
+    interface ViewHolder {
+        /** Bind the data from the notification and payloads to the view */
+        fun bind(
+            viewData: NotificationViewData,
+            payloads: List<*>?,
+            statusDisplayOptions: StatusDisplayOptions
+        )
+    }
+
+    init {
+        stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
+    }
+
+    override fun getItemViewType(position: Int): Int {
+        return NotificationViewKind.from(getItem(position)?.type).ordinal
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+        val inflater = LayoutInflater.from(parent.context)
+
+        return when (NotificationViewKind.values()[viewType]) {
+            NotificationViewKind.STATUS -> {
+                StatusViewHolder(
+                    ItemStatusBinding.inflate(inflater, parent, false),
+                    statusActionListener,
+                    accountId
+                )
+            }
+            NotificationViewKind.NOTIFICATION -> {
+                StatusNotificationViewHolder(
+                    ItemStatusNotificationBinding.inflate(inflater, parent, false),
+                    statusActionListener,
+                    notificationActionListener,
+                    absoluteTimeFormatter
+                )
+            }
+            NotificationViewKind.FOLLOW -> {
+                FollowViewHolder(
+                    ItemFollowBinding.inflate(inflater, parent, false),
+                    notificationActionListener
+                )
+            }
+            NotificationViewKind.FOLLOW_REQUEST -> {
+                FollowRequestViewHolder(
+                    ItemFollowRequestBinding.inflate(inflater, parent, false),
+                    accountActionListener,
+                    showHeader = true
+                )
+            }
+            NotificationViewKind.REPORT -> {
+                ReportNotificationViewHolder(
+                    ItemReportNotificationBinding.inflate(inflater, parent, false),
+                    notificationActionListener
+                )
+            }
+            else -> {
+                FallbackNotificationViewHolder(
+                    SimpleListItem1Binding.inflate(inflater, parent, false)
+                )
+            }
+        }
+    }
+
+    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+        bindViewHolder(holder, position, null)
+    }
+
+    override fun onBindViewHolder(
+        holder: RecyclerView.ViewHolder,
+        position: Int,
+        payloads: MutableList<Any>
+    ) {
+        bindViewHolder(holder, position, payloads)
+    }
+
+    private fun bindViewHolder(
+        holder: RecyclerView.ViewHolder,
+        position: Int,
+        payloads: List<*>?
+    ) {
+        getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) }
+    }
+
+    /**
+     * Notification view holder to use if no other type is appropriate. Should never normally
+     * be used, but is useful when migrating code.
+     */
+    private class FallbackNotificationViewHolder(
+        val binding: SimpleListItem1Binding
+    ) : ViewHolder, RecyclerView.ViewHolder(binding.root) {
+        override fun bind(
+            viewData: NotificationViewData,
+            payloads: List<*>?,
+            statusDisplayOptions: StatusDisplayOptions
+        ) {
+            binding.text1.text = viewData.statusViewData?.content
+        }
+    }
+}

+ 184 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt

@@ -0,0 +1,184 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.util.Log
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.network.MastodonApi
+import com.keylesspalace.tusky.util.HttpHeaderLink
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import okhttp3.Headers
+import retrofit2.Response
+import javax.inject.Inject
+
+/** Models next/prev links from the "Links" header in an API response */
+data class Links(val next: String?, val prev: String?)
+
+/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
+class NotificationsPagingSource @Inject constructor(
+    private val mastodonApi: MastodonApi,
+    private val notificationFilter: Set<Notification.Type>
+) : PagingSource<String, Notification>() {
+    override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
+        Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
+
+        try {
+            val response = when (params) {
+                is LoadParams.Refresh -> {
+                    getInitialPage(params)
+                }
+                is LoadParams.Append -> mastodonApi.notifications(
+                    maxId = params.key,
+                    limit = params.loadSize,
+                    excludes = notificationFilter
+                )
+                is LoadParams.Prepend -> mastodonApi.notifications(
+                    minId = params.key,
+                    limit = params.loadSize,
+                    excludes = notificationFilter
+                )
+            }
+
+            if (!response.isSuccessful) {
+                return LoadResult.Error(Throwable(response.errorBody().toString()))
+            }
+
+            val links = getPageLinks(response.headers()["link"])
+            return LoadResult.Page(
+                data = response.body()!!,
+                nextKey = links.next,
+                prevKey = links.prev
+            )
+        } catch (e: Exception) {
+            return LoadResult.Error(e)
+        }
+    }
+
+    /**
+     * Fetch the initial page of notifications, using params.key as the ID of the initial
+     * notification to fetch.
+     *
+     * - If there is no key, a page of the most recent notifications is returned
+     * - If the notification exists, and is not filtered, a page of notifications is returned
+     * - If the notification does not exist, or is filtered, the page of notifications immediately
+     *   before is returned
+     * - If there is no page of notifications immediately before then the page immediately after
+     *   is returned
+     */
+    private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
+        // If the key is null this is straightforward, just return the most recent notifications.
+        val key = params.key
+            ?: return@coroutineScope mastodonApi.notifications(
+                limit = params.loadSize,
+                excludes = notificationFilter
+            )
+
+        // It's important to return *something* from this state. If an empty page is returned
+        // (even with next/prev links) Pager3 assumes there is no more data to load and stops.
+        //
+        // In addition, the Mastodon API does not let you fetch a page that contains a given key.
+        // You can fetch the page immediately before the key, or the page immediately after, but
+        // you can not fetch the page itself.
+
+        // First, try and get the notification itself, and the notifications immediately before
+        // it. This is so that a full page of results can be returned. Returning just the
+        // single notification means the displayed list can jump around a bit as more data is
+        // loaded.
+        //
+        // Make both requests, and wait for the first to complete.
+        val deferredNotification = async { mastodonApi.notification(id = key) }
+        val deferredNotificationPage = async {
+            mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
+        }
+
+        val notification = deferredNotification.await()
+        if (notification.isSuccessful) {
+            // If this was successful we must still check that the user is not filtering this type
+            // of notification, as fetching a single notification ignores filters. Returning this
+            // notification if the user is filtering the type is wrong.
+            notification.body()?.let { body ->
+                if (!notificationFilter.contains(body.type)) {
+                    // Notification is *not* filtered. We can return this, but need the next page of
+                    // notifications as well
+
+                    // Collect all notifications in to this list
+                    val notifications = mutableListOf(body)
+                    val notificationPage = deferredNotificationPage.await()
+                    if (notificationPage.isSuccessful) {
+                        notificationPage.body()?.let {
+                            notifications.addAll(it)
+                        }
+                    }
+
+                    // "notifications" now contains at least one notification we can return, and
+                    // hopefully a full page.
+
+                    // Build correct max_id and min_id links for the response. The "min_id" to use
+                    // when fetching the next page is the same as "key". The "max_id" is the ID of
+                    // the oldest notification in the list.
+                    val maxId = notifications.last().id
+                    val headers = Headers.Builder()
+                        .add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
+                        .build()
+
+                    return@coroutineScope Response.success(notifications, headers)
+                }
+            }
+        }
+
+        // The user's last read notification was missing or is filtered. Use the page of
+        // notifications chronologically older than their desired notification.
+        deferredNotificationPage.await().apply {
+            if (this.isSuccessful) return@coroutineScope this
+        }
+
+        // There were no notifications older than the user's desired notification. Return the page
+        // of notifications immediately newer than their desired notification.
+        return@coroutineScope mastodonApi.notifications(
+            minId = key,
+            limit = params.loadSize,
+            excludes = notificationFilter
+        )
+    }
+
+    private fun getPageLinks(linkHeader: String?): Links {
+        val links = HttpHeaderLink.parse(linkHeader)
+        return Links(
+            next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
+                "max_id"
+            ),
+            prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
+                "min_id"
+            )
+        )
+    }
+
+    override fun getRefreshKey(state: PagingState<String, Notification>): String? {
+        return state.anchorPosition?.let { anchorPosition ->
+            val anchorPage = state.closestPageToPosition(anchorPosition)
+            anchorPage?.prevKey ?: anchorPage?.nextKey
+        }
+    }
+
+    companion object {
+        private const val TAG = "NotificationsPagingSource"
+    }
+}

+ 74 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.util.Log
+import androidx.paging.InvalidatingPagingSourceFactory
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.PagingSource
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.network.MastodonApi
+import kotlinx.coroutines.flow.Flow
+import okhttp3.ResponseBody
+import retrofit2.Response
+import javax.inject.Inject
+
+class NotificationsRepository @Inject constructor(
+    private val mastodonApi: MastodonApi
+) {
+    private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
+
+    /**
+     * @return flow of Mastodon [Notification], excluding all types in [filter].
+     * Notifications are loaded in [pageSize] increments.
+     */
+    fun getNotificationsStream(
+        filter: Set<Notification.Type>,
+        pageSize: Int = PAGE_SIZE,
+        initialKey: String? = null
+    ): Flow<PagingData<Notification>> {
+        Log.d(TAG, "getNotificationsStream(), filtering: $filter")
+
+        factory = InvalidatingPagingSourceFactory {
+            NotificationsPagingSource(mastodonApi, filter)
+        }
+
+        return Pager(
+            config = PagingConfig(pageSize = pageSize),
+            initialKey = initialKey,
+            pagingSourceFactory = factory!!
+        ).flow
+    }
+
+    /** Invalidate the active paging source, see [PagingSource.invalidate] */
+    fun invalidate() {
+        factory?.invalidate()
+    }
+
+    /** Clear notifications */
+    suspend fun clearNotifications(): Response<ResponseBody> {
+        return mastodonApi.clearNotifications()
+    }
+
+    companion object {
+        private const val TAG = "NotificationsRepository"
+        private const val PAGE_SIZE = 30
+    }
+}

+ 522 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt

@@ -0,0 +1,522 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.annotation.StringRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.map
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.appstore.BlockEvent
+import com.keylesspalace.tusky.appstore.EventHub
+import com.keylesspalace.tusky.appstore.MuteConversationEvent
+import com.keylesspalace.tusky.appstore.MuteEvent
+import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
+import com.keylesspalace.tusky.components.timeline.util.ifExpected
+import com.keylesspalace.tusky.db.AccountManager
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.Poll
+import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.usecase.TimelineCases
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.util.deserialize
+import com.keylesspalace.tusky.util.serialize
+import com.keylesspalace.tusky.util.toViewData
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+import com.keylesspalace.tusky.viewdata.StatusViewData
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.rx3.asFlow
+import kotlinx.coroutines.rx3.await
+import retrofit2.HttpException
+import javax.inject.Inject
+
+data class UiState(
+    /** Filtered notification types */
+    val activeFilter: Set<Notification.Type> = emptySet(),
+
+    /** True if the UI to filter and clear notifications should be shown */
+    val showFilterOptions: Boolean = false,
+
+    /** True if the FAB should be shown while scrolling */
+    val showFabWhileScrolling: Boolean = true
+)
+
+/** Preferences the UI reacts to */
+data class UiPrefs(
+    val showFabWhileScrolling: Boolean,
+    val showFilter: Boolean
+) {
+    companion object {
+        /** Relevant preference keys. Changes to any of these trigger a display update */
+        val prefKeys = setOf(
+            PrefKeys.FAB_HIDE,
+            PrefKeys.SHOW_NOTIFICATIONS_FILTER
+        )
+    }
+}
+
+/** Parent class for all UI actions, fallible or infallible. */
+sealed class UiAction
+
+/** Actions the user can trigger from the UI. These actions may fail. */
+sealed class FallibleUiAction : UiAction() {
+    /** Clear all notifications */
+    object ClearNotifications : FallibleUiAction()
+}
+
+/**
+ * Actions the user can trigger from the UI that either cannot fail, or if they do fail,
+ * do not show an error.
+ */
+sealed class InfallibleUiAction : UiAction() {
+    /** Apply a new filter to the notification list */
+    // This saves the list to the local database, which triggers a refresh of the data.
+    // Saving the data can't fail, which is why this is infallible. Refreshing the
+    // data may fail, but that's handled by the paging system / adapter refresh logic.
+    data class ApplyFilter(val filter: Set<Notification.Type>) : InfallibleUiAction()
+
+    /**
+     * User is leaving the fragment, save the ID of the visible notification.
+     *
+     * Infallible because if it fails there's nowhere to show the error, and nothing the user
+     * can do.
+     */
+    data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
+}
+
+/** Actions the user can trigger on an individual notification. These may fail. */
+sealed class NotificationAction : FallibleUiAction() {
+    data class AcceptFollowRequest(val accountId: String) : NotificationAction()
+
+    data class RejectFollowRequest(val accountId: String) : NotificationAction()
+}
+
+sealed class UiSuccess {
+    // These three are from menu items on the status. Currently they don't come to the
+    // viewModel as actions, they're noticed when events are posted. That will change,
+    // but for the moment we can still report them to the UI. Typically, receiving any
+    // of these three should trigger the UI to refresh.
+
+    /** A user was blocked */
+    object Block : UiSuccess()
+
+    /** A user was muted */
+    object Mute : UiSuccess()
+
+    /** A conversation was muted */
+    object MuteConversation : UiSuccess()
+}
+
+/** The result of a successful action on a notification */
+sealed class NotificationActionSuccess(
+    /** String resource with an error message to show the user */
+    @StringRes val msg: Int,
+
+    /**
+     * The original action, in case additional information is required from it to display the
+     * message.
+     */
+    open val action: NotificationAction
+) : UiSuccess() {
+    data class AcceptFollowRequest(override val action: NotificationAction) :
+        NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action)
+    data class RejectFollowRequest(override val action: NotificationAction) :
+        NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action)
+
+    companion object {
+        fun from(action: NotificationAction) = when (action) {
+            is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action)
+            is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action)
+        }
+    }
+}
+
+/** Actions the user can trigger on an individual status */
+sealed class StatusAction(
+    open val statusViewData: StatusViewData.Concrete
+) : FallibleUiAction() {
+    /** Set the bookmark state for a status */
+    data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
+        StatusAction(statusViewData)
+
+    /** Set the favourite state for a status */
+    data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
+        StatusAction(statusViewData)
+
+    /** Set the reblog state for a status */
+    data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
+        StatusAction(statusViewData)
+
+    /** Vote in a poll */
+    data class VoteInPoll(
+        val poll: Poll,
+        val choices: List<Int>,
+        override val statusViewData: StatusViewData.Concrete
+    ) : StatusAction(statusViewData)
+}
+
+/** Changes to a status' visible state after API calls */
+sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
+    data class Bookmark(override val action: StatusAction.Bookmark) :
+        StatusActionSuccess(action)
+
+    data class Favourite(override val action: StatusAction.Favourite) :
+        StatusActionSuccess(action)
+
+    data class Reblog(override val action: StatusAction.Reblog) :
+        StatusActionSuccess(action)
+
+    data class VoteInPoll(override val action: StatusAction.VoteInPoll) :
+        StatusActionSuccess(action)
+
+    companion object {
+        fun from(action: StatusAction) = when (action) {
+            is StatusAction.Bookmark -> Bookmark(action)
+            is StatusAction.Favourite -> Favourite(action)
+            is StatusAction.Reblog -> Reblog(action)
+            is StatusAction.VoteInPoll -> VoteInPoll(action)
+        }
+    }
+}
+
+/** Errors from fallible view model actions that the UI will need to show */
+sealed class UiError(
+    /** The exception associated with the error */
+    open val exception: Exception,
+
+    /** String resource with an error message to show the user */
+    @StringRes val message: Int,
+
+    /** The action that failed. Can be resent to retry the action */
+    open val action: UiAction? = null
+) {
+    data class ClearNotifications(override val exception: Exception) : UiError(
+        exception,
+        R.string.ui_error_clear_notifications
+    )
+
+    data class Bookmark(
+        override val exception: Exception,
+        override val action: StatusAction.Bookmark
+    ) : UiError(exception, R.string.ui_error_bookmark, action)
+
+    data class Favourite(
+        override val exception: Exception,
+        override val action: StatusAction.Favourite
+    ) : UiError(exception, R.string.ui_error_favourite, action)
+
+    data class Reblog(
+        override val exception: Exception,
+        override val action: StatusAction.Reblog
+    ) : UiError(exception, R.string.ui_error_reblog, action)
+
+    data class VoteInPoll(
+        override val exception: Exception,
+        override val action: StatusAction.VoteInPoll
+    ) : UiError(exception, R.string.ui_error_vote, action)
+
+    data class AcceptFollowRequest(
+        override val exception: Exception,
+        override val action: NotificationAction.AcceptFollowRequest
+    ) : UiError(exception, R.string.ui_error_accept_follow_request, action)
+
+    data class RejectFollowRequest(
+        override val exception: Exception,
+        override val action: NotificationAction.RejectFollowRequest
+    ) : UiError(exception, R.string.ui_error_reject_follow_request, action)
+
+    companion object {
+        fun make(exception: Exception, action: FallibleUiAction) = when (action) {
+            is StatusAction.Bookmark -> Bookmark(exception, action)
+            is StatusAction.Favourite -> Favourite(exception, action)
+            is StatusAction.Reblog -> Reblog(exception, action)
+            is StatusAction.VoteInPoll -> VoteInPoll(exception, action)
+            is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action)
+            is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action)
+            FallibleUiAction.ClearNotifications -> ClearNotifications(exception)
+        }
+    }
+}
+
+@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+class NotificationsViewModel @Inject constructor(
+    private val repository: NotificationsRepository,
+    private val preferences: SharedPreferences,
+    private val accountManager: AccountManager,
+    private val timelineCases: TimelineCases,
+    private val eventHub: EventHub
+) : ViewModel() {
+
+    val uiState: StateFlow<UiState>
+
+    /** Flow of changes to statusDisplayOptions, for use by the UI */
+    val statusDisplayOptions: StateFlow<StatusDisplayOptions>
+
+    val pagingData: Flow<PagingData<NotificationViewData>>
+
+    /** Flow of user actions received from the UI */
+    private val uiAction = MutableSharedFlow<UiAction>()
+
+    /** Flow of successful action results */
+    // Note: These are a SharedFlow instead of a StateFlow because success or error state does not
+    // need to be retained. A message is shown once to a user and then dismissed. Re-collecting the
+    // flow (e.g., after a device orientation change) should not re-show the most recent success or
+    // error message, as it will be confusing to the user.
+    val uiSuccess = MutableSharedFlow<UiSuccess>()
+
+    /** Flow of transient errors for the UI to present */
+    val uiError = MutableSharedFlow<UiError>()
+
+    /** Accept UI actions in to actionStateFlow */
+    val accept: (UiAction) -> Unit = { action ->
+        viewModelScope.launch { uiAction.emit(action) }
+    }
+
+    init {
+        // Handle changes to notification filters
+        val notificationFilter = uiAction
+            .filterIsInstance<InfallibleUiAction.ApplyFilter>()
+            .distinctUntilChanged()
+            // Save each change back to the active account
+            .onEach { action ->
+                Log.d(TAG, "notificationFilter: $action")
+                accountManager.activeAccount?.let { account ->
+                    account.notificationsFilter = serialize(action.filter)
+                    accountManager.saveAccount(account)
+                }
+            }
+            // Load the initial filter from the active account
+            .onStart {
+                emit(
+                    InfallibleUiAction.ApplyFilter(
+                        filter = deserialize(accountManager.activeAccount?.notificationsFilter)
+                    )
+                )
+            }
+
+        // Save the visible notification ID
+        viewModelScope.launch {
+            uiAction
+                .filterIsInstance<InfallibleUiAction.SaveVisibleId>()
+                .distinctUntilChanged()
+                .collectLatest { action ->
+                    Log.d(TAG, "Saving visible ID: ${action.visibleId}")
+                    accountManager.activeAccount?.let { account ->
+                        account.lastNotificationId = action.visibleId
+                        accountManager.saveAccount(account)
+                    }
+                }
+        }
+
+        // Set initial status display options from the user's preferences.
+        //
+        // Then collect future preference changes and emit new values in to
+        // statusDisplayOptions if necessary.
+        statusDisplayOptions = MutableStateFlow(
+            StatusDisplayOptions.from(
+                preferences,
+                accountManager.activeAccount!!
+            )
+        )
+
+        viewModelScope.launch {
+            eventHub.events.asFlow()
+                .filterIsInstance<PreferenceChangedEvent>()
+                .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) }
+                .map {
+                    statusDisplayOptions.value.make(
+                        preferences,
+                        it.preferenceKey,
+                        accountManager.activeAccount!!
+                    )
+                }
+                .collect {
+                    statusDisplayOptions.emit(it)
+                }
+        }
+
+        // Handle UiAction.ClearNotifications
+        viewModelScope.launch {
+            uiAction.filterIsInstance<FallibleUiAction.ClearNotifications>()
+                .collectLatest {
+                    try {
+                        repository.clearNotifications().apply {
+                            if (this.isSuccessful) {
+                                repository.invalidate()
+                            } else {
+                                uiError.emit(UiError.make(HttpException(this), it))
+                            }
+                        }
+                    } catch (e: Exception) {
+                        ifExpected(e) { uiError.emit(UiError.make(e, it)) }
+                    }
+                }
+        }
+
+        // Handle NotificationAction.*
+        viewModelScope.launch {
+            uiAction.filterIsInstance<NotificationAction>()
+                .debounce(DEBOUNCE_TIMEOUT_MS)
+                .collect { action ->
+                    try {
+                        when (action) {
+                            is NotificationAction.AcceptFollowRequest ->
+                                timelineCases.acceptFollowRequest(action.accountId).await()
+                            is NotificationAction.RejectFollowRequest ->
+                                timelineCases.rejectFollowRequest(action.accountId).await()
+                        }
+                        uiSuccess.emit(NotificationActionSuccess.from(action))
+                    } catch (e: Exception) {
+                        ifExpected(e) { uiError.emit(UiError.make(e, action)) }
+                    }
+                }
+        }
+
+        // Handle StatusAction.*
+        viewModelScope.launch {
+            uiAction.filterIsInstance<StatusAction>()
+                .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps
+                .collect { action ->
+                    try {
+                        when (action) {
+                            is StatusAction.Bookmark ->
+                                timelineCases.bookmark(
+                                    action.statusViewData.actionableId,
+                                    action.state
+                                ).await()
+                            is StatusAction.Favourite ->
+                                timelineCases.favourite(
+                                    action.statusViewData.actionableId,
+                                    action.state
+                                ).await()
+                            is StatusAction.Reblog ->
+                                timelineCases.reblog(
+                                    action.statusViewData.actionableId,
+                                    action.state
+                                ).await()
+                            is StatusAction.VoteInPoll ->
+                                timelineCases.voteInPoll(
+                                    action.statusViewData.actionableId,
+                                    action.poll.id,
+                                    action.choices
+                                ).await()
+                        }
+                        uiSuccess.emit(StatusActionSuccess.from(action))
+                    } catch (e: Exception) {
+                        ifExpected(e) { uiError.emit(UiError.make(e, action)) }
+                    }
+                }
+        }
+
+        // Handle events that should refresh the list
+        viewModelScope.launch {
+            eventHub.events.asFlow().collectLatest {
+                when (it) {
+                    is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
+                    is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
+                    is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
+                }
+            }
+        }
+
+        // The database stores "0" as the last notification ID if notifications have not been
+        // fetched. Convert to null to ensure a full fetch in this case
+        val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) {
+            "0" -> null
+            else -> id
+        }
+        Log.d(TAG, "Restoring at $lastNotificationId")
+
+        pagingData = notificationFilter
+            .flatMapLatest { action ->
+                getNotifications(filters = action.filter, initialKey = lastNotificationId)
+            }
+            .cachedIn(viewModelScope)
+
+        uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
+            UiState(
+                activeFilter = filter.filter,
+                showFilterOptions = prefs.showFilter,
+                showFabWhileScrolling = prefs.showFabWhileScrolling
+            )
+        }.stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
+            initialValue = UiState()
+        )
+    }
+
+    private fun getNotifications(
+        filters: Set<Notification.Type>,
+        initialKey: String? = null
+    ): Flow<PagingData<NotificationViewData>> {
+        return repository.getNotificationsStream(filter = filters, initialKey = initialKey)
+            .map { pagingData ->
+                pagingData.map { notification ->
+                    notification.toViewData(
+                        isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
+                            !(notification.status?.actionableStatus?.sensitive ?: false),
+                        isExpanded = statusDisplayOptions.value.openSpoiler,
+                        isCollapsed = true
+                    )
+                }
+            }
+    }
+
+    /**
+     * @return Flow of relevant preferences that change the UI
+     */
+    // TODO: Preferences should be in a repository
+    private fun getUiPrefs() = eventHub.events.asFlow()
+        .filterIsInstance<PreferenceChangedEvent>()
+        .filter { UiPrefs.prefKeys.contains(it.preferenceKey) }
+        .map { toPrefs() }
+        .onStart { emit(toPrefs()) }
+
+    private fun toPrefs() = UiPrefs(
+        showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false),
+        showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
+    )
+
+    companion object {
+        private const val TAG = "NotificationsViewModel"
+        private const val DEBOUNCE_TIMEOUT_MS = 500L
+    }
+}

+ 1 - 1
app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt

@@ -150,7 +150,7 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) {
 
 private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
     buildMap {
-        Notification.Type.asList.forEach {
+        Notification.Type.visibleTypes.forEach {
             put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
         }
     }

+ 385 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt

@@ -0,0 +1,385 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.content.Context
+import android.graphics.PorterDuff
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.text.InputFilter
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.TextUtils
+import android.text.format.DateUtils
+import android.text.style.StyleSpan
+import android.view.View
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import at.connyduck.sparkbutton.helpers.Utils
+import com.bumptech.glide.Glide
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
+import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
+import com.keylesspalace.tusky.entity.Emoji
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.interfaces.LinkListener
+import com.keylesspalace.tusky.interfaces.StatusActionListener
+import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
+import com.keylesspalace.tusky.util.SmartLengthInputFilter
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.util.emojify
+import com.keylesspalace.tusky.util.getRelativeTimeSpanString
+import com.keylesspalace.tusky.util.loadAvatar
+import com.keylesspalace.tusky.util.setClickableText
+import com.keylesspalace.tusky.util.unicodeWrap
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+import com.keylesspalace.tusky.viewdata.StatusViewData
+import java.util.Date
+
+/**
+ * View holder for a status with an activity to be notified about (posted, boosted,
+ * favourited, or edited, per [NotificationViewKind.from]).
+ *
+ * Shows a line with the activity, and who initiated the activity. Clicking this should
+ * go to the profile page for the initiator.
+ *
+ * Displays the original status below that. Clicking this should go to the original
+ * status in context.
+ */
+internal class StatusNotificationViewHolder(
+    private val binding: ItemStatusNotificationBinding,
+    private val statusActionListener: StatusActionListener,
+    private val notificationActionListener: NotificationActionListener,
+    private val absoluteTimeFormatter: AbsoluteTimeFormatter
+) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
+    private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
+        R.dimen.avatar_radius_48dp
+    )
+    private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
+        R.dimen.avatar_radius_36dp
+    )
+    private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
+        R.dimen.avatar_radius_24dp
+    )
+
+    override fun bind(
+        viewData: NotificationViewData,
+        payloads: List<*>?,
+        statusDisplayOptions: StatusDisplayOptions
+    ) {
+        val statusViewData = viewData.statusViewData
+        if (payloads.isNullOrEmpty()) {
+            // Hide null statuses. Shouldn't happen according to the spec, but some servers
+            // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
+            if (statusViewData == null) {
+                showNotificationContent(false)
+            } else {
+                showNotificationContent(true)
+                val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
+                setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
+                setUsername(account.username)
+                setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
+                if (viewData.type == Notification.Type.STATUS ||
+                    viewData.type == Notification.Type.UPDATE
+                ) {
+                    setAvatar(
+                        account.avatar,
+                        account.bot,
+                        statusDisplayOptions.animateAvatars,
+                        statusDisplayOptions.showBotOverlay
+                    )
+                } else {
+                    setAvatars(
+                        account.avatar,
+                        viewData.account.avatar,
+                        statusDisplayOptions.animateAvatars
+                    )
+                }
+
+                binding.notificationContainer.setOnClickListener {
+                    notificationActionListener.onViewThreadForStatus(statusViewData.status)
+                }
+                binding.notificationContent.setOnClickListener {
+                    notificationActionListener.onViewThreadForStatus(statusViewData.status)
+                }
+                binding.notificationTopText.setOnClickListener {
+                    notificationActionListener.onViewAccount(viewData.account.id)
+                }
+            }
+            setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
+        } else {
+            for (item in payloads) {
+                if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
+                    setCreatedAt(
+                        statusViewData.status.actionableStatus.createdAt,
+                        statusDisplayOptions.useAbsoluteTime
+                    )
+                }
+            }
+        }
+    }
+
+    private fun showNotificationContent(show: Boolean) {
+        binding.statusNameBar.visibility = if (show) View.VISIBLE else View.GONE
+        binding.notificationContentWarningDescription.visibility =
+            if (show) View.VISIBLE else View.GONE
+        binding.notificationContentWarningButton.visibility =
+            if (show) View.VISIBLE else View.GONE
+        binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE
+        binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE
+        binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE
+    }
+
+    private fun setDisplayName(name: String, emojis: List<Emoji>?, animateEmojis: Boolean) {
+        val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
+        binding.statusDisplayName.text = emojifiedName
+    }
+
+    private fun setUsername(name: String) {
+        val context = binding.statusUsername.context
+        val format = context.getString(R.string.post_username_format)
+        val usernameText = String.format(format, name)
+        binding.statusUsername.text = usernameText
+    }
+
+    private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
+        if (useAbsoluteTime) {
+            binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
+        } else {
+            // This is the visible timestampInfo.
+            val readout: String
+            /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
+             * as 17 meters instead of minutes. */
+            val readoutAloud: CharSequence
+            if (createdAt != null) {
+                val then = createdAt.time
+                val now = Date().time
+                readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
+                readoutAloud = DateUtils.getRelativeTimeSpanString(
+                    then,
+                    now,
+                    DateUtils.SECOND_IN_MILLIS,
+                    DateUtils.FORMAT_ABBREV_RELATIVE
+                )
+            } else {
+                // unknown minutes~
+                readout = "?m"
+                readoutAloud = "? minutes"
+            }
+            binding.statusMetaInfo.text = readout
+            binding.statusMetaInfo.contentDescription = readoutAloud
+        }
+    }
+
+    private fun getIconWithColor(
+        context: Context,
+        @DrawableRes drawable: Int,
+        @ColorRes color: Int
+    ): Drawable? {
+        val icon = ContextCompat.getDrawable(context, drawable)
+        icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
+        return icon
+    }
+
+    private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
+        binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
+        loadAvatar(
+            statusAvatarUrl,
+            binding.notificationStatusAvatar,
+            avatarRadius48dp,
+            animateAvatars
+        )
+        if (showBotOverlay && isBot) {
+            binding.notificationNotificationAvatar.visibility = View.VISIBLE
+            Glide.with(binding.notificationNotificationAvatar)
+                .load(R.drawable.bot_badge)
+                .into(binding.notificationNotificationAvatar)
+        } else {
+            binding.notificationNotificationAvatar.visibility = View.GONE
+        }
+    }
+
+    private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
+        val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
+        binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
+        loadAvatar(
+            statusAvatarUrl,
+            binding.notificationStatusAvatar,
+            avatarRadius36dp,
+            animateAvatars
+        )
+        binding.notificationNotificationAvatar.visibility = View.VISIBLE
+        loadAvatar(
+            notificationAvatarUrl,
+            binding.notificationNotificationAvatar,
+            avatarRadius24dp,
+            animateAvatars
+        )
+    }
+
+    fun setMessage(
+        notificationViewData: NotificationViewData,
+        listener: LinkListener,
+        animateEmojis: Boolean
+    ) {
+        val statusViewData = notificationViewData.statusViewData
+        val displayName = notificationViewData.account.name.unicodeWrap()
+        val type = notificationViewData.type
+        val context = binding.notificationTopText.context
+        val format: String
+        val icon: Drawable?
+        when (type) {
+            Notification.Type.FAVOURITE -> {
+                icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
+                format = context.getString(R.string.notification_favourite_format)
+            }
+            Notification.Type.REBLOG -> {
+                icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
+                format = context.getString(R.string.notification_reblog_format)
+            }
+            Notification.Type.STATUS -> {
+                icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
+                format = context.getString(R.string.notification_subscription_format)
+            }
+            Notification.Type.UPDATE -> {
+                icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
+                format = context.getString(R.string.notification_update_format)
+            }
+            else -> {
+                icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
+                format = context.getString(R.string.notification_favourite_format)
+            }
+        }
+        binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
+            icon,
+            null,
+            null,
+            null
+        )
+        val wholeMessage = String.format(format, displayName)
+        val str = SpannableStringBuilder(wholeMessage)
+        val displayNameIndex = format.indexOf("%s")
+        str.setSpan(
+            StyleSpan(Typeface.BOLD),
+            displayNameIndex,
+            displayNameIndex + displayName.length,
+            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+        )
+        val emojifiedText = str.emojify(
+            notificationViewData.account.emojis,
+            binding.notificationTopText,
+            animateEmojis
+        )
+        binding.notificationTopText.text = emojifiedText
+        if (statusViewData != null) {
+            val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
+            binding.notificationContentWarningDescription.visibility =
+                if (hasSpoiler) View.VISIBLE else View.GONE
+            binding.notificationContentWarningButton.visibility =
+                if (hasSpoiler) View.VISIBLE else View.GONE
+            if (statusViewData.isExpanded) {
+                binding.notificationContentWarningButton.setText(
+                    R.string.post_content_warning_show_less
+                )
+            } else {
+                binding.notificationContentWarningButton.setText(
+                    R.string.post_content_warning_show_more
+                )
+            }
+            binding.notificationContentWarningButton.setOnClickListener {
+                if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
+                    notificationActionListener.onExpandedChange(
+                        !statusViewData.isExpanded,
+                        bindingAdapterPosition
+                    )
+                }
+                binding.notificationContent.visibility =
+                    if (statusViewData.isExpanded) View.GONE else View.VISIBLE
+            }
+            setupContentAndSpoiler(listener, statusViewData, animateEmojis)
+        }
+    }
+
+    private fun setupContentAndSpoiler(
+        listener: LinkListener,
+        statusViewData: StatusViewData.Concrete,
+        animateEmojis: Boolean
+    ) {
+        val shouldShowContentIfSpoiler = statusViewData.isExpanded
+        val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
+        if (!shouldShowContentIfSpoiler && hasSpoiler) {
+            binding.notificationContent.visibility = View.GONE
+        } else {
+            binding.notificationContent.visibility = View.VISIBLE
+        }
+        val content = statusViewData.content
+        val emojis = statusViewData.actionable.emojis
+        if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
+            binding.buttonToggleNotificationContent.setOnClickListener {
+                val position = bindingAdapterPosition
+                if (position != RecyclerView.NO_POSITION) {
+                    notificationActionListener.onNotificationContentCollapsedChange(
+                        !statusViewData.isCollapsed,
+                        position
+                    )
+                }
+            }
+            binding.buttonToggleNotificationContent.visibility = View.VISIBLE
+            if (statusViewData.isCollapsed) {
+                binding.buttonToggleNotificationContent.setText(
+                    R.string.post_content_warning_show_more
+                )
+                binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
+            } else {
+                binding.buttonToggleNotificationContent.setText(
+                    R.string.post_content_warning_show_less
+                )
+                binding.notificationContent.filters = NO_INPUT_FILTER
+            }
+        } else {
+            binding.buttonToggleNotificationContent.visibility = View.GONE
+            binding.notificationContent.filters = NO_INPUT_FILTER
+        }
+        val emojifiedText =
+            content.emojify(
+                emojis,
+                binding.notificationContent,
+                animateEmojis
+            )
+        setClickableText(
+            binding.notificationContent,
+            emojifiedText,
+            statusViewData.actionable.mentions,
+            statusViewData.actionable.tags,
+            listener
+        )
+        val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify(
+            statusViewData.actionable.emojis,
+            binding.notificationContentWarningDescription,
+            animateEmojis
+        )
+        binding.notificationContentWarningDescription.text = emojifiedContentWarning
+    }
+
+    companion object {
+        private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
+        private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
+    }
+}

+ 60 - 0
app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt

@@ -0,0 +1,60 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import com.keylesspalace.tusky.adapter.StatusViewHolder
+import com.keylesspalace.tusky.databinding.ItemStatusBinding
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.interfaces.StatusActionListener
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+
+internal class StatusViewHolder(
+    binding: ItemStatusBinding,
+    private val statusActionListener: StatusActionListener,
+    private val accountId: String
+) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
+
+    override fun bind(
+        viewData: NotificationViewData,
+        payloads: List<*>?,
+        statusDisplayOptions: StatusDisplayOptions
+    ) {
+        val statusViewData = viewData.statusViewData
+        if (statusViewData == null) {
+            // Hide null statuses. Shouldn't happen according to the spec, but some servers
+            // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
+            showStatusContent(false)
+        } else {
+            if (payloads.isNullOrEmpty()) {
+                showStatusContent(true)
+            }
+            setupWithStatus(
+                statusViewData,
+                statusActionListener,
+                statusDisplayOptions,
+                payloads?.firstOrNull()
+            )
+        }
+        if (viewData.type == Notification.Type.POLL) {
+            setPollInfo(accountId == viewData.account.id)
+        } else {
+            hideStatusInfo()
+        }
+    }
+}

+ 3 - 1
app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt

@@ -156,7 +156,9 @@ class ReportStatusesFragment :
             confirmReblogs = preferences.getBoolean("confirmReblogs", true),
             confirmFavourites = preferences.getBoolean("confirmFavourites", false),
             hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
-            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+            showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
+            openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
         )
 
         adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this)

+ 7 - 1
app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt

@@ -48,6 +48,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
 import com.keylesspalace.tusky.components.report.ReportActivity
 import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
 import com.keylesspalace.tusky.db.AccountEntity
+import com.keylesspalace.tusky.db.AccountManager
 import com.keylesspalace.tusky.entity.Attachment
 import com.keylesspalace.tusky.entity.Status
 import com.keylesspalace.tusky.entity.Status.Mention
@@ -62,8 +63,11 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
 import com.keylesspalace.tusky.viewdata.StatusViewData
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
+import javax.inject.Inject
 
 class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
+    @Inject
+    lateinit var accountManager: AccountManager
 
     override val data: Flow<PagingData<StatusViewData.Concrete>>
         get() = viewModel.statusesFlow
@@ -83,7 +87,9 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
             confirmReblogs = preferences.getBoolean("confirmReblogs", true),
             confirmFavourites = preferences.getBoolean("confirmFavourites", false),
             hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
-            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+            showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
+            openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
         )
 
         binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))

+ 6 - 4
app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt

@@ -191,7 +191,9 @@ class TimelineFragment :
             confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
             confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
             hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
-            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+            showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
+            openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
         )
         adapter = TimelinePagingAdapter(
             statusDisplayOptions,
@@ -226,16 +228,16 @@ class TimelineFragment :
                     is LoadState.NotLoading -> {
                         if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
                             binding.statusView.show()
-                            binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
+                            binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
                         }
                     }
                     is LoadState.Error -> {
                         binding.statusView.show()
 
                         if ((loadState.refresh as LoadState.Error).error is IOException) {
-                            binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
+                            binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network)
                         } else {
-                            binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
+                            binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic)
                         }
                     }
                     is LoadState.Loading -> {

+ 3 - 1
app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt

@@ -112,7 +112,9 @@ class ViewThreadFragment :
             confirmReblogs = preferences.getBoolean("confirmReblogs", true),
             confirmFavourites = preferences.getBoolean("confirmFavourites", false),
             hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
-            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+            showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
+            openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
         )
         adapter = ThreadAdapter(statusDisplayOptions, this)
     }

+ 5 - 0
app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt

@@ -64,6 +64,11 @@ data class AccountEntity(
     var alwaysShowSensitiveMedia: Boolean = false,
     /** True if content behind a content warning is shown by default */
     var alwaysOpenSpoiler: Boolean = false,
+
+    /**
+     * True if the "Download media previews" preference is true. This implies
+     * that media previews are shown as well as downloaded.
+     */
     var mediaPreviewEnabled: Boolean = true,
     var lastNotificationId: String = "0",
     var activeNotifications: String = "[]",

+ 1 - 1
app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt

@@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
 import com.keylesspalace.tusky.components.accountlist.AccountListFragment
 import com.keylesspalace.tusky.components.conversation.ConversationsFragment
 import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
+import com.keylesspalace.tusky.components.notifications.NotificationsFragment
 import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
 import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
 import com.keylesspalace.tusky.components.preference.PreferencesFragment
@@ -34,7 +35,6 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment
 import com.keylesspalace.tusky.components.trending.TrendingFragment
 import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
 import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
-import com.keylesspalace.tusky.fragment.NotificationsFragment
 import dagger.Module
 import dagger.android.ContributesAndroidInjector
 

+ 23 - 0
app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt

@@ -1,3 +1,20 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
 // from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455
 
 package com.keylesspalace.tusky.di
@@ -13,6 +30,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
 import com.keylesspalace.tusky.components.drafts.DraftsViewModel
 import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel
 import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
+import com.keylesspalace.tusky.components.notifications.NotificationsViewModel
 import com.keylesspalace.tusky.components.report.ReportViewModel
 import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
 import com.keylesspalace.tusky.components.search.SearchViewModel
@@ -147,6 +165,11 @@ abstract class ViewModelModule {
 
     @Binds
     @IntoMap
+    @ViewModelKey(NotificationsViewModel::class)
+    internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel
+
+    @Binds
+    @IntoMap
     @ViewModelKey(TrendingViewModel::class)
     internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel
 

+ 38 - 18
app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt

@@ -15,11 +15,13 @@
 
 package com.keylesspalace.tusky.entity
 
+import androidx.annotation.StringRes
 import com.google.gson.JsonDeserializationContext
 import com.google.gson.JsonDeserializer
 import com.google.gson.JsonElement
 import com.google.gson.JsonParseException
 import com.google.gson.annotations.JsonAdapter
+import com.keylesspalace.tusky.R
 
 data class Notification(
     val type: Type,
@@ -29,23 +31,42 @@ data class Notification(
     val report: Report?,
 ) {
 
+    /** From https://docs.joinmastodon.org/entities/Notification/#type */
     @JsonAdapter(NotificationTypeAdapter::class)
-    enum class Type(val presentation: String) {
-        UNKNOWN("unknown"),
-        MENTION("mention"),
-        REBLOG("reblog"),
-        FAVOURITE("favourite"),
-        FOLLOW("follow"),
-        FOLLOW_REQUEST("follow_request"),
-        POLL("poll"),
-        STATUS("status"),
-        SIGN_UP("admin.sign_up"),
-        UPDATE("update"),
-        REPORT("admin.report"),
-        ;
+    enum class Type(val presentation: String, @StringRes val uiString: Int) {
+        UNKNOWN("unknown", R.string.notification_unknown_name),
 
-        companion object {
+        /** Someone mentioned you */
+        MENTION("mention", R.string.notification_mention_name),
+
+        /** Someone boosted one of your statuses */
+        REBLOG("reblog", R.string.notification_boost_name),
+
+        /** Someone favourited one of your statuses */
+        FAVOURITE("favourite", R.string.notification_favourite_name),
+
+        /** Someone followed you */
+        FOLLOW("follow", R.string.notification_follow_name),
+
+        /** Someone requested to follow you */
+        FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name),
+
+        /** A poll you have voted in or created has ended */
+        POLL("poll", R.string.notification_poll_name),
+
+        /** Someone you enabled notifications for has posted a status */
+        STATUS("status", R.string.notification_subscription_name),
 
+        /** Someone signed up (optionally sent to admins) */
+        SIGN_UP("admin.sign_up", R.string.notification_sign_up_name),
+
+        /** A status you interacted with has been updated */
+        UPDATE("update", R.string.notification_update_name),
+
+        /** A new report has been filed */
+        REPORT("admin.report", R.string.notification_report_name);
+
+        companion object {
             @JvmStatic
             fun byString(s: String): Type {
                 values().forEach {
@@ -54,7 +75,9 @@ data class Notification(
                 }
                 return UNKNOWN
             }
-            val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT)
+
+            /** Notification types for UI display (omits UNKNOWN) */
+            val visibleTypes = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT)
         }
 
         override fun toString(): String {
@@ -86,9 +109,6 @@ data class Notification(
         }
     }
 
-    /** Helper for Java */
-    fun copyWithStatus(status: Status?): Notification = copy(status = status)
-
     // for Pleroma compatibility that uses Mention type
     fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
         if (type == Type.MENTION && status != null) {

+ 0 - 1273
app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java

@@ -1,1273 +0,0 @@
-/* Copyright 2017 Andrew Dawson
- *
- * This file is a part of Tusky.
- *
- * This program is free software; you can redistribute it and/or modify it under the terms of the
- * GNU General Public License as published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
- * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
- * Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with Tusky; if not,
- * see <http://www.gnu.org/licenses>. */
-
-package com.keylesspalace.tusky.fragment;
-
-import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
-import static autodispose2.AutoDispose.autoDisposable;
-import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.SharedPreferences;
-import android.os.Bundle;
-import android.util.Log;
-import android.util.SparseBooleanArray;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ArrayAdapter;
-import android.widget.ListView;
-import android.widget.PopupWindow;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-import androidx.arch.core.util.Function;
-import androidx.coordinatorlayout.widget.CoordinatorLayout;
-import androidx.core.util.Pair;
-import androidx.core.view.MenuProvider;
-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;
-
-import com.google.android.material.appbar.AppBarLayout;
-import com.google.android.material.floatingactionbutton.FloatingActionButton;
-import com.keylesspalace.tusky.R;
-import com.keylesspalace.tusky.adapter.NotificationsAdapter;
-import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
-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.components.notifications.NotificationHelper;
-import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding;
-import com.keylesspalace.tusky.db.AccountEntity;
-import com.keylesspalace.tusky.db.AccountManager;
-import com.keylesspalace.tusky.di.Injectable;
-import com.keylesspalace.tusky.entity.Notification;
-import com.keylesspalace.tusky.entity.Poll;
-import com.keylesspalace.tusky.entity.Relationship;
-import com.keylesspalace.tusky.entity.Status;
-import com.keylesspalace.tusky.interfaces.AccountActionListener;
-import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
-import com.keylesspalace.tusky.interfaces.ReselectableFragment;
-import com.keylesspalace.tusky.interfaces.StatusActionListener;
-import com.keylesspalace.tusky.settings.PrefKeys;
-import com.keylesspalace.tusky.util.CardViewMode;
-import com.keylesspalace.tusky.util.Either;
-import com.keylesspalace.tusky.util.HttpHeaderLink;
-import com.keylesspalace.tusky.util.LinkHelper;
-import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
-import com.keylesspalace.tusky.util.ListUtils;
-import com.keylesspalace.tusky.util.NotificationTypeConverterKt;
-import com.keylesspalace.tusky.util.PairedList;
-import com.keylesspalace.tusky.util.StatusDisplayOptions;
-import com.keylesspalace.tusky.util.ViewDataUtils;
-import com.keylesspalace.tusky.view.EndlessOnScrollListener;
-import com.keylesspalace.tusky.viewdata.AttachmentViewData;
-import com.keylesspalace.tusky.viewdata.NotificationViewData;
-import com.keylesspalace.tusky.viewdata.StatusViewData;
-
-import java.io.IOException;
-import java.util.ArrayList;
-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;
-
-import javax.inject.Inject;
-
-import at.connyduck.sparkbutton.helpers.Utils;
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.Observable;
-import io.reactivex.rxjava3.core.Single;
-import io.reactivex.rxjava3.disposables.CompositeDisposable;
-import io.reactivex.rxjava3.disposables.Disposable;
-import kotlin.Unit;
-import kotlin.collections.CollectionsKt;
-import kotlin.jvm.functions.Function1;
-
-public class NotificationsFragment extends SFragment implements
-        SwipeRefreshLayout.OnRefreshListener,
-        StatusActionListener,
-        NotificationsAdapter.NotificationActionListener,
-        AccountActionListener,
-        Injectable,
-        MenuProvider,
-        ReselectableFragment {
-    private static final String TAG = "NotificationF"; // logging tag
-
-    private static final int LOAD_AT_ONCE = 30;
-    private int maxPlaceholderId = 0;
-
-    private final Set<Notification.Type> notificationFilter = new HashSet<>();
-
-    private final CompositeDisposable disposables = new CompositeDisposable();
-
-    private enum FetchEnd {
-        TOP,
-        BOTTOM,
-        MIDDLE
-    }
-
-    /**
-     * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor
-     * and reuse in different places as needed.
-     */
-    private static final class Placeholder {
-        final long id;
-
-        public static Placeholder getInstance(long id) {
-            return new Placeholder(id);
-        }
-
-        private Placeholder(long id) {
-            this.id = id;
-        }
-    }
-
-    @Inject
-    AccountManager accountManager;
-    @Inject
-    EventHub eventHub;
-
-    private FragmentTimelineNotificationsBinding binding;
-
-    private LinearLayoutManager layoutManager;
-    private EndlessOnScrollListener scrollListener;
-    private NotificationsAdapter adapter;
-    private boolean hideFab;
-    private boolean topLoading;
-    private boolean bottomLoading;
-    private String bottomId;
-    private boolean alwaysShowSensitiveMedia;
-    private boolean alwaysOpenSpoiler;
-    private boolean showNotificationsFilter;
-    private boolean showingError;
-
-    // Each element is either a Notification for loading data or a Placeholder
-    private final PairedList<Either<Placeholder, Notification>, NotificationViewData> notifications
-            = new PairedList<>(new Function<>() {
-        @Override
-        public NotificationViewData apply(Either<Placeholder, Notification> input) {
-            if (input.isRight()) {
-                Notification notification = input.asRight()
-                    .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId());
-
-                boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive();
-
-                return ViewDataUtils.notificationToViewData(
-                    notification,
-                    alwaysShowSensitiveMedia || !sensitiveStatus,
-                    alwaysOpenSpoiler,
-                    true
-                );
-            } else {
-                return new NotificationViewData.Placeholder(input.asLeft().id, false);
-            }
-        }
-    });
-
-    public static NotificationsFragment newInstance() {
-        NotificationsFragment fragment = new NotificationsFragment();
-        Bundle arguments = new Bundle();
-        fragment.setArguments(arguments);
-        return fragment;
-    }
-
-    @Nullable
-    @Override
-    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
-                             @Nullable Bundle savedInstanceState) {
-        requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED);
-
-        binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false);
-
-        @NonNull Context context = inflater.getContext(); // from inflater to silence warning
-        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext());
-
-        boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true);
-        // Clear notifications on filter visibility change to force refresh
-        if (showNotificationsFilterSetting != showNotificationsFilter)
-            notifications.clear();
-        showNotificationsFilter = showNotificationsFilterSetting;
-
-        // Setup the SwipeRefreshLayout.
-        binding.swipeRefreshLayout.setOnRefreshListener(this);
-        binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue);
-
-        loadNotificationsFilter();
-
-        // Setup the RecyclerView.
-        binding.recyclerView.setHasFixedSize(true);
-        layoutManager = new LinearLayoutManager(context);
-        binding.recyclerView.setLayoutManager(layoutManager);
-        binding.recyclerView.setAccessibilityDelegateCompat(
-                new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> {
-                    NotificationViewData notification = notifications.getPairedItemOrNull(pos);
-                    // We support replies only for now
-                    if (notification instanceof NotificationViewData.Concrete) {
-                        return ((NotificationViewData.Concrete) notification).getStatusViewData();
-                    } else {
-                        return null;
-                    }
-                }));
-
-        binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL));
-
-        StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions(
-                preferences.getBoolean("animateGifAvatars", false),
-                accountManager.getActiveAccount().getMediaPreviewEnabled(),
-                preferences.getBoolean("absoluteTimeView", false),
-                preferences.getBoolean("showBotOverlay", true),
-                preferences.getBoolean("useBlurhash", true),
-                CardViewMode.NONE,
-                preferences.getBoolean("confirmReblogs", true),
-                preferences.getBoolean("confirmFavourites", false),
-                preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
-                preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
-        );
-
-        adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(),
-                dataSource, statusDisplayOptions, this, this, this);
-        alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
-        alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
-        binding.recyclerView.setAdapter(adapter);
-
-        topLoading = false;
-        bottomLoading = false;
-        bottomId = null;
-
-        updateAdapter();
-
-        binding.buttonClear.setOnClickListener(v -> confirmClearNotifications());
-        binding.buttonFilter.setOnClickListener(v -> showFilterMenu());
-
-        if (notifications.isEmpty()) {
-            binding.swipeRefreshLayout.setEnabled(false);
-            sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1);
-        } else {
-            binding.progressBar.setVisibility(View.GONE);
-        }
-
-        ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
-
-        updateFilterVisibility();
-
-        return binding.getRoot();
-    }
-
-    @Override
-    public void onDestroyView() {
-        super.onDestroyView();
-        binding = null;
-    }
-
-    @Override
-    public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) {
-        menuInflater.inflate(R.menu.fragment_notifications, menu);
-    }
-
-    @Override
-    public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
-        if (menuItem.getItemId() == R.id.action_refresh) {
-            binding.swipeRefreshLayout.setRefreshing(true);
-            onRefresh();
-            return true;
-        }
-
-        return false;
-    }
-
-    private void updateFilterVisibility() {
-        CoordinatorLayout.LayoutParams params =
-                (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams();
-        if (showNotificationsFilter && !showingError) {
-            binding.appBarOptions.setExpanded(true, false);
-            binding.appBarOptions.setVisibility(View.VISIBLE);
-            // Set content behaviour to hide filter on scroll
-            params.setBehavior(new AppBarLayout.ScrollingViewBehavior());
-        } else {
-            binding.appBarOptions.setExpanded(false, false);
-            binding.appBarOptions.setVisibility(View.GONE);
-            // Clear behaviour to hide app bar
-            params.setBehavior(null);
-        }
-    }
-
-    private void confirmClearNotifications() {
-        new AlertDialog.Builder(requireContext())
-                .setMessage(R.string.notification_clear_text)
-                .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications())
-                .setNegativeButton(android.R.string.cancel, null)
-                .show();
-    }
-
-    @Override
-    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
-        super.onViewCreated(view, savedInstanceState);
-        Activity activity = getActivity();
-        if (activity == null) throw new AssertionError("Activity is null");
-
-        // This is delayed until onActivityCreated solely because MainActivity.composeButton
-        // isn't guaranteed to be set until then.
-        // Use a modified scroll listener that both loads more notificationsEnabled as it
-        // goes, and hides the compose button on down-scroll.
-        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity);
-        hideFab = preferences.getBoolean("fabHide", false);
-        scrollListener = new EndlessOnScrollListener(layoutManager) {
-            @Override
-            public void onScrolled(@NonNull RecyclerView view, int dx, int dy) {
-                super.onScrolled(view, dx, dy);
-
-                ActionButtonActivity activity = (ActionButtonActivity) getActivity();
-                FloatingActionButton composeButton = activity.getActionButton();
-
-                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
-            public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) {
-                NotificationsFragment.this.onLoadMore();
-            }
-        };
-
-        binding.recyclerView.addOnScrollListener(scrollListener);
-
-        eventHub.getEvents()
-            .observeOn(AndroidSchedulers.mainThread())
-            .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
-            .subscribe(event -> {
-                if (event instanceof FavoriteEvent) {
-                    setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite());
-                } else if (event instanceof BookmarkEvent) {
-                    setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark());
-                } else if (event instanceof ReblogEvent) {
-                    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) {
-                    onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey());
-                }
-            });
-    }
-
-    @Override
-    public void onRefresh() {
-        binding.statusView.setVisibility(View.GONE);
-        this.showingError = false;
-        Either<Placeholder, Notification> first = CollectionsKt.firstOrNull(this.notifications);
-        String topId;
-        if (first != null && first.isRight()) {
-            topId = first.asRight().getId();
-        } else {
-            topId = null;
-        }
-        sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1);
-    }
-
-    @Override
-    public void onReply(int position) {
-        super.reply(notifications.get(position).asRight().getStatus());
-    }
-
-    @Override
-    public void onReblog(final boolean reblog, final int position) {
-        final Notification notification = notifications.get(position).asRight();
-        final Status status = notification.getStatus();
-        Objects.requireNonNull(status, "Reblog on notification without status");
-        timelineCases.reblog(status.getId(), reblog)
-                .observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this)))
-                .subscribe(
-                        (newStatus) -> setReblogForStatus(status.getId(), reblog),
-                        (t) -> Log.d(getClass().getSimpleName(),
-                                "Failed to reblog status: " + status.getId(), t)
-                );
-    }
-
-    private void setReblogForStatus(String statusId, boolean reblog) {
-        updateStatus(statusId, (s) -> s.copyWithReblogged(reblog));
-    }
-
-    @Override
-    public void onFavourite(final boolean favourite, final int position) {
-        final Notification notification = notifications.get(position).asRight();
-        final Status status = notification.getStatus();
-
-        timelineCases.favourite(status.getId(), favourite)
-                .observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this)))
-                .subscribe(
-                        (newStatus) -> setFavouriteForStatus(status.getId(), favourite),
-                        (t) -> Log.d(getClass().getSimpleName(),
-                                "Failed to favourite status: " + status.getId(), t)
-                );
-    }
-
-    private void setFavouriteForStatus(String statusId, boolean favourite) {
-        updateStatus(statusId, (s) -> s.copyWithFavourited(favourite));
-    }
-
-    @Override
-    public void onBookmark(final boolean bookmark, final int position) {
-        final Notification notification = notifications.get(position).asRight();
-        final Status status = notification.getStatus();
-
-        timelineCases.bookmark(status.getActionableId(), bookmark)
-                .observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this)))
-                .subscribe(
-                        (newStatus) -> setBookmarkForStatus(status.getId(), bookmark),
-                        (t) -> Log.d(getClass().getSimpleName(),
-                                "Failed to bookmark status: " + status.getId(), t)
-                );
-    }
-
-    private void setBookmarkForStatus(String statusId, boolean bookmark) {
-        updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark));
-    }
-
-    public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
-        final Notification notification = notifications.get(position).asRight();
-        final Status status = notification.getStatus().getActionableStatus();
-        timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices)
-                .observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this)))
-                .subscribe(
-                        (newPoll) -> setVoteForPoll(status, newPoll),
-                        (t) -> Log.d(TAG,
-                                "Failed to vote in poll: " + status.getId(), t)
-                );
-    }
-
-    private void setVoteForPoll(Status status, Poll poll) {
-        updateStatus(status.getId(), (s) -> s.copyWithPoll(poll));
-    }
-
-    @Override
-    public void onMore(@NonNull View view, int position) {
-        Notification notification = notifications.get(position).asRight();
-        super.more(notification.getStatus(), view, position);
-    }
-
-    @Override
-    public void onViewMedia(int position, int attachmentIndex, @Nullable View view) {
-        Notification notification = notifications.get(position).asRightOrNull();
-        if (notification == null || notification.getStatus() == null) return;
-        Status status = notification.getStatus();
-        super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view);
-    }
-
-    @Override
-    public void onViewThread(int position) {
-        Notification notification = notifications.get(position).asRight();
-        Status status = notification.getStatus();
-        if (status == null) return;
-        super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
-    }
-
-    @Override
-    public void onOpenReblog(int position) {
-        Notification notification = notifications.get(position).asRight();
-        onViewAccount(notification.getAccount().getId());
-    }
-
-    @Override
-    public void onExpandedChange(boolean expanded, int position) {
-        updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded));
-    }
-
-    @Override
-    public void onContentHiddenChange(boolean isShowing, int position) {
-        updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing));
-    }
-
-    private void setPinForStatus(String statusId, boolean pinned) {
-        updateStatus(statusId, status -> status.copyWithPinned(pinned));
-    }
-
-    @Override
-    public void onLoadMore(int position) {
-        // Check bounds before accessing list,
-        if (notifications.size() >= position && position > 0) {
-            Notification previous = notifications.get(position - 1).asRightOrNull();
-            Notification next = notifications.get(position + 1).asRightOrNull();
-            if (previous == null || next == null) {
-                Log.e(TAG, "Failed to load more, invalid placeholder position: " + position);
-                return;
-            }
-            sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position);
-            Placeholder placeholder = notifications.get(position).asLeft();
-            NotificationViewData notificationViewData =
-                    new NotificationViewData.Placeholder(placeholder.id, true);
-            notifications.setPairedItem(position, notificationViewData);
-            updateAdapter();
-        } else {
-            Log.d(TAG, "error loading more");
-        }
-    }
-
-    @Override
-    public void onContentCollapsedChange(boolean isCollapsed, int position) {
-        updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
-    }
-
-    private void updateStatus(String statusId, Function<Status, Status> 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<StatusViewData.Concrete, StatusViewData.Concrete> 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;
-
-        NotificationViewData.Concrete newViewData =
-                oldViewData.copyWithStatus(mapper.apply(oldStatusViewData));
-        notifications.setPairedItem(position, newViewData);
-
-        updateAdapter();
-    }
-
-    @Override
-    public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) {
-        onContentCollapsedChange(isCollapsed, position);
-    }
-
-    private void clearNotifications() {
-        // Cancel all ongoing requests
-        binding.swipeRefreshLayout.setRefreshing(false);
-        resetNotificationsLoad();
-
-        // Show friend elephant
-        binding.statusView.setVisibility(View.VISIBLE);
-        binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
-        updateFilterVisibility();
-
-        // Update adapter
-        updateAdapter();
-
-        // Execute clear notifications request
-        mastodonApi.clearNotifications()
-                .observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
-                .subscribe(
-                        response -> {
-                            // Nothing to do
-                        },
-                        throwable -> {
-                            // Reload notifications on failure
-                            fullyRefreshWithProgressBar(true);
-                        });
-    }
-
-    private void resetNotificationsLoad() {
-        disposables.clear();
-        bottomLoading = false;
-        topLoading = false;
-
-        // Disable load more
-        bottomId = null;
-
-        // Clear exists notifications
-        notifications.clear();
-    }
-
-
-    private void showFilterMenu() {
-        List<Notification.Type> notificationsList = Notification.Type.Companion.getAsList();
-        List<String> list = new ArrayList<>();
-        for (Notification.Type type : notificationsList) {
-            list.add(getNotificationText(type));
-        }
-
-        ArrayAdapter<String> adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list);
-        PopupWindow window = new PopupWindow(getContext());
-        View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false);
-        final ListView listView = view.findViewById(R.id.listView);
-        view.findViewById(R.id.buttonApply)
-                .setOnClickListener(v -> {
-                    SparseBooleanArray checkedItems = listView.getCheckedItemPositions();
-                    Set<Notification.Type> excludes = new HashSet<>();
-                    for (int i = 0; i < notificationsList.size(); i++) {
-                        if (!checkedItems.get(i, false))
-                            excludes.add(notificationsList.get(i));
-                    }
-                    window.dismiss();
-                    applyFilterChanges(excludes);
-
-                });
-
-        listView.setAdapter(adapter);
-        listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
-        for (int i = 0; i < notificationsList.size(); i++) {
-            if (!notificationFilter.contains(notificationsList.get(i)))
-                listView.setItemChecked(i, true);
-        }
-        window.setContentView(view);
-        window.setFocusable(true);
-        window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
-        window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
-        window.showAsDropDown(binding.buttonFilter);
-
-    }
-
-    private String getNotificationText(Notification.Type type) {
-        switch (type) {
-            case MENTION:
-                return getString(R.string.notification_mention_name);
-            case FAVOURITE:
-                return getString(R.string.notification_favourite_name);
-            case REBLOG:
-                return getString(R.string.notification_boost_name);
-            case FOLLOW:
-                return getString(R.string.notification_follow_name);
-            case FOLLOW_REQUEST:
-                return getString(R.string.notification_follow_request_name);
-            case POLL:
-                return getString(R.string.notification_poll_name);
-            case STATUS:
-                return getString(R.string.notification_subscription_name);
-            case SIGN_UP:
-                return getString(R.string.notification_sign_up_name);
-            case UPDATE:
-                return getString(R.string.notification_update_name);
-            case REPORT:
-                return getString(R.string.notification_report_name);
-            default:
-                return "Unknown";
-        }
-    }
-
-    private void applyFilterChanges(Set<Notification.Type> newSet) {
-        List<Notification.Type> notifications = Notification.Type.Companion.getAsList();
-        boolean isChanged = false;
-        for (Notification.Type type : notifications) {
-            if (notificationFilter.contains(type) && !newSet.contains(type)) {
-                notificationFilter.remove(type);
-                isChanged = true;
-            } else if (!notificationFilter.contains(type) && newSet.contains(type)) {
-                notificationFilter.add(type);
-                isChanged = true;
-            }
-        }
-        if (isChanged) {
-            saveNotificationsFilter();
-            fullyRefreshWithProgressBar(true);
-        }
-
-    }
-
-    private void loadNotificationsFilter() {
-        AccountEntity account = accountManager.getActiveAccount();
-        if (account != null) {
-            notificationFilter.clear();
-            notificationFilter.addAll(NotificationTypeConverterKt.deserialize(
-                    account.getNotificationsFilter()));
-        }
-    }
-
-    private void saveNotificationsFilter() {
-        AccountEntity account = accountManager.getActiveAccount();
-        if (account != null) {
-            account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter));
-            accountManager.saveAccount(account);
-        }
-    }
-
-    @Override
-    public void onViewTag(@NonNull String tag) {
-        super.viewTag(tag);
-    }
-
-    @Override
-    public void onViewAccount(@NonNull String id) {
-        super.viewAccount(id);
-    }
-
-    @Override
-    public void onMute(boolean mute, String id, int position, boolean notifications) {
-        // No muting from notifications yet
-    }
-
-    @Override
-    public void onBlock(boolean block, String id, int position) {
-        // No blocking from notifications yet
-    }
-
-    @Override
-    public void onRespondToFollowRequest(boolean accept, String id, int position) {
-        Single<Relationship> request = accept ?
-                mastodonApi.authorizeFollowRequest(id) :
-                mastodonApi.rejectFollowRequest(id);
-        request.observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
-                .subscribe(
-                        (relationship) -> fullyRefreshWithProgressBar(true),
-                        (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))
-                );
-    }
-
-    @Override
-    public void onViewStatusForNotificationId(String notificationId) {
-        for (Either<Placeholder, Notification> either : notifications) {
-            Notification notification = either.asRightOrNull();
-            if (notification != null && notification.getId().equals(notificationId)) {
-                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);
-    }
-
-    @Override
-    public void onViewReport(String reportId) {
-        LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId));
-    }
-
-    private void onPreferenceChanged(String key) {
-        switch (key) {
-            case "fabHide": {
-                hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false);
-                break;
-            }
-            case "mediaPreviewEnabled": {
-                boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled();
-                if (enabled != adapter.isMediaPreviewEnabled()) {
-                    adapter.setMediaPreviewEnabled(enabled);
-                    fullyRefresh();
-                }
-                break;
-            }
-            case "showNotificationsFilter": {
-                if (isAdded()) {
-                    showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true);
-                    updateFilterVisibility();
-                    fullyRefreshWithProgressBar(true);
-                }
-                break;
-            }
-        }
-    }
-
-    @Override
-    public void removeItem(int position) {
-        notifications.remove(position);
-        updateAdapter();
-    }
-
-    private void removeAllByAccountId(String accountId) {
-        // Using iterator to safely remove items while iterating
-        Iterator<Either<Placeholder, Notification>> iterator = notifications.iterator();
-        while (iterator.hasNext()) {
-            Either<Placeholder, Notification> notification = iterator.next();
-            Notification maybeNotification = notification.asRightOrNull();
-            if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) {
-                iterator.remove();
-            }
-        }
-        updateAdapter();
-    }
-
-    private void onLoadMore() {
-        if (bottomId == null) {
-            // Already loaded everything
-            return;
-        }
-
-        // Check for out-of-bounds when loading
-        // This is required to allow full-timeline reloads of collapsible statuses when the settings
-        // change.
-        if (notifications.size() > 0) {
-            Either<Placeholder, Notification> last = notifications.get(notifications.size() - 1);
-            if (last.isRight()) {
-                final Placeholder placeholder = newPlaceholder();
-                notifications.add(new Either.Left<>(placeholder));
-                NotificationViewData viewData =
-                        new NotificationViewData.Placeholder(placeholder.id, true);
-                notifications.setPairedItem(notifications.size() - 1, viewData);
-                updateAdapter();
-            }
-        }
-
-        sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1);
-    }
-
-    private Placeholder newPlaceholder() {
-        Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId);
-        maxPlaceholderId--;
-        return placeholder;
-    }
-
-    private void jumpToTop() {
-        if (isAdded()) {
-            binding.appBarOptions.setExpanded(true, false);
-            layoutManager.scrollToPosition(0);
-            scrollListener.reset();
-        }
-    }
-
-    private void sendFetchNotificationsRequest(String fromId, String uptoId,
-                                               final FetchEnd fetchEnd, final int pos) {
-        // If there is a fetch already ongoing, record however many fetches are requested and
-        // fulfill them after it's complete.
-        if (fetchEnd == FetchEnd.TOP && topLoading) {
-            return;
-        }
-        if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
-            return;
-        }
-        if (fetchEnd == FetchEnd.TOP) {
-            topLoading = true;
-        }
-        if (fetchEnd == FetchEnd.BOTTOM) {
-            bottomLoading = true;
-        }
-
-        Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
-                .observeOn(AndroidSchedulers.mainThread())
-                .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
-                .subscribe(
-                        response -> {
-                            if (response.isSuccessful()) {
-                                String linkHeader = response.headers().get("Link");
-                                onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
-                            } else {
-                                onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
-                            }
-                        },
-                        throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos));
-        disposables.add(notificationCall);
-    }
-
-    private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
-                                             FetchEnd fetchEnd, int pos) {
-        List<HttpHeaderLink> links = HttpHeaderLink.Companion.parse(linkHeader);
-        HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next");
-        String fromId = null;
-        if (next != null) {
-            fromId = next.getUri().getQueryParameter("max_id");
-        }
-
-        switch (fetchEnd) {
-            case TOP: {
-                update(notifications, this.notifications.isEmpty() ? fromId : null);
-                break;
-            }
-            case MIDDLE: {
-                replacePlaceholderWithNotifications(notifications, pos);
-                break;
-            }
-            case BOTTOM: {
-
-                if (!this.notifications.isEmpty()
-                        && !this.notifications.get(this.notifications.size() - 1).isRight()) {
-                    this.notifications.remove(this.notifications.size() - 1);
-                    updateAdapter();
-                }
-
-                if (adapter.getItemCount() > 1) {
-                    addItems(notifications, fromId);
-                } else {
-                    update(notifications, fromId);
-                }
-
-                break;
-            }
-        }
-
-        saveNewestNotificationId(notifications);
-
-        if (fetchEnd == FetchEnd.TOP) {
-            topLoading = false;
-        }
-        if (fetchEnd == FetchEnd.BOTTOM) {
-            bottomLoading = false;
-        }
-
-        if (notifications.size() == 0 && adapter.getItemCount() == 0) {
-            binding.statusView.setVisibility(View.VISIBLE);
-            binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
-        }
-
-        updateFilterVisibility();
-        binding.swipeRefreshLayout.setEnabled(true);
-        binding.swipeRefreshLayout.setRefreshing(false);
-        binding.progressBar.setVisibility(View.GONE);
-    }
-
-    private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) {
-        binding.swipeRefreshLayout.setRefreshing(false);
-        if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) {
-            Placeholder placeholder = notifications.get(position).asLeft();
-            NotificationViewData placeholderVD =
-                    new NotificationViewData.Placeholder(placeholder.id, false);
-            notifications.setPairedItem(position, placeholderVD);
-            updateAdapter();
-        } else if (this.notifications.isEmpty()) {
-            binding.statusView.setVisibility(View.VISIBLE);
-            binding.swipeRefreshLayout.setEnabled(false);
-            this.showingError = true;
-            if (throwable instanceof IOException) {
-                binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> {
-                    binding.progressBar.setVisibility(View.VISIBLE);
-                    this.onRefresh();
-                    return Unit.INSTANCE;
-                });
-            } else {
-                binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> {
-                    binding.progressBar.setVisibility(View.VISIBLE);
-                    this.onRefresh();
-                    return Unit.INSTANCE;
-                });
-            }
-            updateFilterVisibility();
-        }
-        Log.e(TAG, "Fetch failure: " + throwable.getMessage());
-
-        if (fetchEnd == FetchEnd.TOP) {
-            topLoading = false;
-        }
-        if (fetchEnd == FetchEnd.BOTTOM) {
-            bottomLoading = false;
-        }
-
-        binding.progressBar.setVisibility(View.GONE);
-    }
-
-    private void saveNewestNotificationId(List<Notification> notifications) {
-
-        AccountEntity account = accountManager.getActiveAccount();
-        if (account != null) {
-            String lastNotificationId = account.getLastNotificationId();
-
-            for (Notification noti : notifications) {
-                if (isLessThan(lastNotificationId, noti.getId())) {
-                    lastNotificationId = noti.getId();
-                }
-            }
-
-            if (!account.getLastNotificationId().equals(lastNotificationId)) {
-                Log.d(TAG, "saving newest noti id: " + lastNotificationId);
-                account.setLastNotificationId(lastNotificationId);
-                accountManager.saveAccount(account);
-            }
-        }
-    }
-
-    private void update(@Nullable List<Notification> newNotifications, @Nullable String fromId) {
-        if (ListUtils.isEmpty(newNotifications)) {
-            updateAdapter();
-            return;
-        }
-        if (fromId != null) {
-            bottomId = fromId;
-        }
-        List<Either<Placeholder, Notification>> liftedNew =
-                liftNotificationList(newNotifications);
-        if (notifications.isEmpty()) {
-            notifications.addAll(liftedNew);
-        } else {
-            int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1));
-            if (index > 0) {
-                notifications.subList(0, index).clear();
-            }
-
-            int newIndex = liftedNew.indexOf(notifications.get(0));
-            if (newIndex == -1) {
-                if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) {
-                    liftedNew.add(new Either.Left<>(newPlaceholder()));
-                }
-                notifications.addAll(0, liftedNew);
-            } else {
-                notifications.addAll(0, liftedNew.subList(0, newIndex));
-            }
-        }
-        updateAdapter();
-    }
-
-    private void addItems(List<Notification> newNotifications, @Nullable String fromId) {
-        bottomId = fromId;
-        if (ListUtils.isEmpty(newNotifications)) {
-            return;
-        }
-        int end = notifications.size();
-        List<Either<Placeholder, Notification>> liftedNew = liftNotificationList(newNotifications);
-        Either<Placeholder, Notification> last = notifications.get(end - 1);
-        if (last != null && !liftedNew.contains(last)) {
-            notifications.addAll(liftedNew);
-            updateAdapter();
-        }
-    }
-
-    private void replacePlaceholderWithNotifications(List<Notification> newNotifications, int pos) {
-        // Remove placeholder
-        notifications.remove(pos);
-
-        if (ListUtils.isEmpty(newNotifications)) {
-            updateAdapter();
-            return;
-        }
-
-        List<Either<Placeholder, Notification>> liftedNew = liftNotificationList(newNotifications);
-
-        // If we fetched less posts than in the limit, it means that the hole is not filled
-        // If we fetched at least as much it means that there are more posts to load and we should
-        // insert new placeholder
-        if (newNotifications.size() >= LOAD_AT_ONCE) {
-            liftedNew.add(new Either.Left<>(newPlaceholder()));
-        }
-
-        notifications.addAll(pos, liftedNew);
-        updateAdapter();
-    }
-
-    private final Function1<Notification, Either<Placeholder, Notification>> notificationLifter =
-            Either.Right::new;
-
-    private List<Either<Placeholder, Notification>> liftNotificationList(List<Notification> list) {
-        return CollectionsKt.map(list, notificationLifter);
-    }
-
-    private void fullyRefreshWithProgressBar(boolean isShow) {
-        resetNotificationsLoad();
-        if (isShow) {
-            binding.progressBar.setVisibility(View.VISIBLE);
-            binding.statusView.setVisibility(View.GONE);
-        }
-        updateAdapter();
-        sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1);
-    }
-
-    private void fullyRefresh() {
-        fullyRefreshWithProgressBar(false);
-    }
-
-    @Nullable
-    private Pair<Integer, Notification> findReplyPosition(@NonNull String statusId) {
-        for (int i = 0; i < notifications.size(); i++) {
-            Notification notification = notifications.get(i).asRightOrNull();
-            if (notification != null
-                    && notification.getStatus() != null
-                    && notification.getType() == Notification.Type.MENTION
-                    && (statusId.equals(notification.getStatus().getId())
-                    || (notification.getStatus().getReblog() != null
-                    && statusId.equals(notification.getStatus().getReblog().getId())))) {
-                return new Pair<>(i, notification);
-            }
-        }
-        return null;
-    }
-
-    private void updateAdapter() {
-        differ.submitList(notifications.getPairedCopy());
-    }
-
-    private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() {
-        @Override
-        public void onInserted(int position, int count) {
-            if (isAdded()) {
-                adapter.notifyItemRangeInserted(position, count);
-                Context context = getContext();
-                // scroll up when new items at the top are loaded while being at the start
-                // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724
-                if (position == 0 && context != null && adapter.getItemCount() != count) {
-                    binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30));
-                }
-            }
-        }
-
-        @Override
-        public void onRemoved(int position, int count) {
-            adapter.notifyItemRangeRemoved(position, count);
-        }
-
-        @Override
-        public void onMoved(int fromPosition, int toPosition) {
-            adapter.notifyItemMoved(fromPosition, toPosition);
-        }
-
-        @Override
-        public void onChanged(int position, int count, Object payload) {
-            adapter.notifyItemRangeChanged(position, count, payload);
-        }
-    };
-
-    private final AsyncListDiffer<NotificationViewData>
-            differ = new AsyncListDiffer<>(listUpdateCallback,
-            new AsyncDifferConfig.Builder<>(diffCallback).build());
-
-    private final NotificationsAdapter.AdapterDataSource<NotificationViewData> dataSource =
-        new NotificationsAdapter.AdapterDataSource<>() {
-            @Override
-            public int getItemCount() {
-                return differ.getCurrentList().size();
-            }
-
-            @Override
-            public NotificationViewData getItemAt(int pos) {
-                return differ.getCurrentList().get(pos);
-            }
-        };
-
-    private static final DiffUtil.ItemCallback<NotificationViewData> diffCallback
-            = new DiffUtil.ItemCallback<>() {
-
-        @Override
-        public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) {
-            return oldItem.getViewDataId() == newItem.getViewDataId();
-        }
-
-        @Override
-        public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) {
-            return false;
-        }
-
-        @Nullable
-        @Override
-        public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) {
-            if (oldItem.deepEquals(newItem)) {
-                //  If items are equal - update timestamp only
-                return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED);
-            } else
-                // If items are different - update a whole view holder
-                return null;
-        }
-    };
-
-    @Override
-    public void onResume() {
-        super.onResume();
-
-        NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager);
-
-        String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter();
-        Set<Notification.Type> accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter);
-        if (!notificationFilter.equals(accountNotificationFilter)) {
-            loadNotificationsFilter();
-            fullyRefreshWithProgressBar(true);
-        }
-        startUpdateTimestamp();
-    }
-
-    /**
-     * Start to update adapter every minute to refresh timestamp
-     * If setting absoluteTimeView is false
-     * Auto dispose observable on pause
-     */
-    private void startUpdateTimestamp() {
-        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext());
-        boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
-        if (!useAbsoluteTime) {
-            Observable.interval(0, 1, TimeUnit.MINUTES)
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE)))
-                    .subscribe(
-                            interval -> updateAdapter()
-                    );
-        }
-
-    }
-
-    @Override
-    public void onReselect() {
-        jumpToTop();
-    }
-}

+ 17 - 7
app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt

@@ -123,12 +123,22 @@ interface MastodonApi {
     ): Response<List<Status>>
 
     @GET("api/v1/notifications")
-    fun notifications(
-        @Query("max_id") maxId: String?,
-        @Query("since_id") sinceId: String?,
-        @Query("limit") limit: Int?,
-        @Query("exclude_types[]") excludes: Set<Notification.Type>?
-    ): Single<Response<List<Notification>>>
+    suspend fun notifications(
+        /** Return results older than this ID */
+        @Query("max_id") maxId: String? = null,
+        /** Return results immediately newer than this ID */
+        @Query("min_id") minId: String? = null,
+        /** Maximum number of results to return. Defaults to 15, max is 30 */
+        @Query("limit") limit: Int? = null,
+        /** Types to excludes from the results */
+        @Query("exclude_types[]") excludes: Set<Notification.Type>? = null
+    ): Response<List<Notification>>
+
+    /** Fetch a single notification */
+    @GET("api/v1/notifications/{id}")
+    suspend fun notification(
+        @Path("id") id: String
+    ): Response<Notification>
 
     @GET("api/v1/markers")
     fun markersWithAuth(
@@ -145,7 +155,7 @@ interface MastodonApi {
     ): Single<List<Notification>>
 
     @POST("api/v1/notifications/clear")
-    fun clearNotifications(): Single<ResponseBody>
+    suspend fun clearNotifications(): Response<ResponseBody>
 
     @FormUrlEncoded
     @PUT("api/v1/media/{mediaId}")

+ 9 - 0
app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt

@@ -31,6 +31,7 @@ import com.keylesspalace.tusky.appstore.ReblogEvent
 import com.keylesspalace.tusky.appstore.StatusDeletedEvent
 import com.keylesspalace.tusky.entity.DeletedStatus
 import com.keylesspalace.tusky.entity.Poll
+import com.keylesspalace.tusky.entity.Relationship
 import com.keylesspalace.tusky.entity.Status
 import com.keylesspalace.tusky.network.MastodonApi
 import com.keylesspalace.tusky.util.getServerErrorMessage
@@ -143,6 +144,14 @@ class TimelineCases @Inject constructor(
         }
     }
 
+    fun acceptFollowRequest(accountId: String): Single<Relationship> {
+        return mastodonApi.authorizeFollowRequest(accountId)
+    }
+
+    fun rejectFollowRequest(accountId: String): Single<Relationship> {
+        return mastodonApi.rejectFollowRequest(accountId)
+    }
+
     private fun <T : Any> convertError(e: Throwable): Single<T> {
         return Single.error(TimelineError(e.getServerErrorMessage()))
     }

+ 104 - 2
app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt

@@ -1,5 +1,26 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
 package com.keylesspalace.tusky.util
 
+import android.content.SharedPreferences
+import com.keylesspalace.tusky.db.AccountEntity
+import com.keylesspalace.tusky.settings.PrefKeys
+
 data class StatusDisplayOptions(
     @get:JvmName("animateAvatars")
     val animateAvatars: Boolean,
@@ -20,5 +41,86 @@ data class StatusDisplayOptions(
     @get:JvmName("hideStats")
     val hideStats: Boolean,
     @get:JvmName("animateEmojis")
-    val animateEmojis: Boolean
-)
+    val animateEmojis: Boolean,
+    @get:JvmName("showSensitiveMedia")
+    val showSensitiveMedia: Boolean,
+    @get:JvmName("openSpoiler")
+    val openSpoiler: Boolean
+) {
+
+    /**
+     * @return a new StatusDisplayOptions adapted to whichever preference changed.
+     */
+    fun make(
+        preferences: SharedPreferences,
+        key: String,
+        account: AccountEntity
+    ) = when (key) {
+        PrefKeys.ANIMATE_GIF_AVATARS -> copy(
+            animateAvatars = preferences.getBoolean(key, false)
+        )
+        PrefKeys.MEDIA_PREVIEW_ENABLED -> copy(
+            mediaPreviewEnabled = account.mediaPreviewEnabled
+        )
+        PrefKeys.ABSOLUTE_TIME_VIEW -> copy(
+            useAbsoluteTime = preferences.getBoolean(key, false)
+        )
+        PrefKeys.SHOW_BOT_OVERLAY -> copy(
+            showBotOverlay = preferences.getBoolean(key, true)
+        )
+        PrefKeys.USE_BLURHASH -> copy(
+            useBlurhash = preferences.getBoolean(key, true)
+        )
+        PrefKeys.CONFIRM_FAVOURITES -> copy(
+            confirmFavourites = preferences.getBoolean(key, false)
+        )
+        PrefKeys.CONFIRM_REBLOGS -> copy(
+            confirmReblogs = preferences.getBoolean(key, true)
+        )
+        PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy(
+            hideStats = preferences.getBoolean(key, false)
+        )
+        PrefKeys.ANIMATE_CUSTOM_EMOJIS -> copy(
+            animateEmojis = preferences.getBoolean(key, false)
+        )
+        PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy(
+            showSensitiveMedia = account.alwaysShowSensitiveMedia
+        )
+        PrefKeys.ALWAYS_OPEN_SPOILER -> copy(
+            openSpoiler = account.alwaysOpenSpoiler
+        )
+        else -> { this }
+    }
+
+    companion object {
+        /** Preference keys that, if changed, affect StatusDisplayOptions */
+        val prefKeys = setOf(
+            PrefKeys.ABSOLUTE_TIME_VIEW,
+            PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA,
+            PrefKeys.ALWAYS_OPEN_SPOILER,
+            PrefKeys.ANIMATE_CUSTOM_EMOJIS,
+            PrefKeys.ANIMATE_GIF_AVATARS,
+            PrefKeys.CONFIRM_FAVOURITES,
+            PrefKeys.CONFIRM_REBLOGS,
+            PrefKeys.MEDIA_PREVIEW_ENABLED,
+            PrefKeys.SHOW_BOT_OVERLAY,
+            PrefKeys.USE_BLURHASH,
+            PrefKeys.WELLBEING_HIDE_STATS_POSTS
+        )
+
+        fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions(
+            animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
+            animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+            mediaPreviewEnabled = account.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 = CardViewMode.NONE,
+            confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
+            confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
+            hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
+            showSensitiveMedia = account.alwaysShowSensitiveMedia,
+            openSpoiler = account.alwaysOpenSpoiler
+        )
+    }
+}

+ 19 - 2
app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt

@@ -1,3 +1,20 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
 @file:JvmName("ViewDataUtils")
 
 /* Copyright 2017 Andrew Dawson
@@ -44,8 +61,8 @@ fun Notification.toViewData(
     isShowingContent: Boolean,
     isExpanded: Boolean,
     isCollapsed: Boolean
-): NotificationViewData.Concrete {
-    return NotificationViewData.Concrete(
+): NotificationViewData {
+    return NotificationViewData(
         this.type,
         this.id,
         this.account,

+ 0 - 138
app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java

@@ -1,138 +0,0 @@
-/* Copyright 2017 Andrew Dawson
- *
- * This file is a part of Tusky.
- *
- * This program is free software; you can redistribute it and/or modify it under the terms of the
- * GNU General Public License as published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
- * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
- * Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with Tusky; if not,
- * see <http://www.gnu.org/licenses>. */
-
-package com.keylesspalace.tusky.viewdata;
-
-import androidx.annotation.Nullable;
-
-import com.keylesspalace.tusky.entity.Notification;
-import com.keylesspalace.tusky.entity.Report;
-import com.keylesspalace.tusky.entity.TimelineAccount;
-
-import java.util.Objects;
-
-/**
- * Created by charlag on 12/07/2017.
- * <p>
- * Class to represent data required to display either a notification or a placeholder.
- * It is either a {@link Placeholder} or a {@link Concrete}.
- * It is modelled this way because close relationship between placeholder and concrete notification
- * is fine in this case. Placeholder case is not modelled as a type of notification because
- * invariants would be violated and because it would model domain incorrectly. It is preferable to
- * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and
- * more native.
- */
-public abstract class NotificationViewData {
-    private NotificationViewData() {
-    }
-
-    public abstract long getViewDataId();
-
-    public abstract boolean deepEquals(NotificationViewData other);
-
-    public static final class Concrete extends NotificationViewData {
-        private final Notification.Type type;
-        private final String id;
-        private final TimelineAccount account;
-        @Nullable
-        private final StatusViewData.Concrete statusViewData;
-        @Nullable
-        private final Report report;
-
-        public Concrete(Notification.Type type, String id, TimelineAccount account,
-                        @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) {
-            this.type = type;
-            this.id = id;
-            this.account = account;
-            this.statusViewData = statusViewData;
-            this.report = report;
-        }
-
-        public Notification.Type getType() {
-            return type;
-        }
-
-        public String getId() {
-            return id;
-        }
-
-        public TimelineAccount getAccount() {
-            return account;
-        }
-
-        @Nullable
-        public StatusViewData.Concrete getStatusViewData() {
-            return statusViewData;
-        }
-
-        @Nullable
-        public Report getReport() {
-            return report;
-        }
-
-        @Override
-        public long getViewDataId() {
-            return id.hashCode();
-        }
-
-        @Override
-        public boolean deepEquals(NotificationViewData o) {
-            if (this == o) return true;
-            if (o == null || getClass() != o.getClass()) return false;
-            Concrete concrete = (Concrete) o;
-            return type == concrete.type &&
-                    Objects.equals(id, concrete.id) &&
-                    account.getId().equals(concrete.account.getId()) &&
-                    (Objects.equals(statusViewData, concrete.statusViewData)) &&
-                    (Objects.equals(report, concrete.report));
-        }
-
-        @Override
-        public int hashCode() {
-
-            return Objects.hash(type, id, account, statusViewData);
-        }
-
-        public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) {
-            return new Concrete(type, id, account, statusViewData, report);
-        }
-    }
-
-    public static final class Placeholder extends NotificationViewData {
-        private final long id;
-        private final boolean isLoading;
-
-        public Placeholder(long id, boolean isLoading) {
-            this.id = id;
-            this.isLoading = isLoading;
-        }
-
-        public boolean isLoading() {
-            return isLoading;
-        }
-
-        @Override
-        public long getViewDataId() {
-            return id;
-        }
-
-        @Override
-        public boolean deepEquals(NotificationViewData other) {
-            if (!(other instanceof Placeholder)) return false;
-            Placeholder that = (Placeholder) other;
-            return isLoading == that.isLoading && id == that.id;
-        }
-    }
-}

+ 43 - 0
app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+/*
+ * This file is a part of Tusky.
+ *
+ * This program is free software; you can redistribute it and/or modify it under the terms of the
+ * GNU General Public License as published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+ * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Tusky; if not,
+ * see <http://www.gnu.org/licenses>. */
+package com.keylesspalace.tusky.viewdata
+
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.Report
+import com.keylesspalace.tusky.entity.TimelineAccount
+
+data class NotificationViewData(
+    val type: Notification.Type,
+    val id: String,
+    val account: TimelineAccount,
+    var statusViewData: StatusViewData.Concrete?,
+    val report: Report?
+)

+ 0 - 15
app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt

@@ -91,21 +91,6 @@ sealed class StatusViewData {
         }
 
         /** 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)
         }

+ 18 - 1
app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml

@@ -1,4 +1,21 @@
 <?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright 2023 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 <http://www.gnu.org/licenses>.
+  -->
+
 <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
@@ -82,4 +99,4 @@
             android:visibility="gone"
             tools:visibility="visible" />
     </androidx.coordinatorlayout.widget.CoordinatorLayout>
-</FrameLayout>
+</FrameLayout>

+ 17 - 0
app/src/main/res/layout/fragment_timeline_notifications.xml

@@ -1,4 +1,21 @@
 <?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright 2023 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 <http://www.gnu.org/licenses>.
+  -->
+
 <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"

+ 45 - 0
app/src/main/res/layout/item_notifications_load_state_footer_view.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright 2023 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 <http://www.gnu.org/licenses>.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:padding="8dp">
+    <androidx.core.widget.ContentLoadingProgressBar
+        android:id="@+id/progress_bar"
+        style="?android:attr/progressBarStyle"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+    <TextView
+        android:id="@+id/error_msg"
+        android:textColor="?android:textColorPrimary"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:textAlignment="center"
+        tools:text="@string/socket_timeout_exception"/>
+    <Button
+        android:id="@+id/retry_button"
+        style="@style/TuskyButton.Outlined"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:text="@string/action_retry"/>
+</LinearLayout>

+ 0 - 19
app/src/main/res/layout/notifications_filter.xml

@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:orientation="vertical" android:layout_width="200dp"
-    android:layout_height="wrap_content"
-    android:background="?android:attr/windowBackground">
-    <ListView
-        android:id="@+id/listView"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_weight="1" />
-    <Button
-        android:id="@+id/buttonApply"
-        style="@style/TuskyButton.TextButton"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_weight="0"
-        android:text="@string/filter_apply"
-        android:textSize="?attr/status_text_medium" />
-</LinearLayout>

+ 27 - 0
app/src/main/res/layout/simple_list_item_1.xml

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2006 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<!-- Copied from platform so it will work with view binding -->
+
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@android:id/text1"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:textAppearance="?android:attr/textAppearanceListItemSmall"
+    android:gravity="center_vertical"
+    android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+    android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+    android:minHeight="?android:attr/listPreferredItemHeightSmall" />

+ 17 - 0
app/src/main/res/menu/fragment_notifications.xml

@@ -1,4 +1,21 @@
 <?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright 2023 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 <http://www.gnu.org/licenses>.
+  -->
+
 <menu xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
     <item

+ 35 - 0
app/src/main/res/values/strings.xml

@@ -1,3 +1,20 @@
+<!--
+  ~ Copyright 2023 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 <http://www.gnu.org/licenses>.
+  -->
+
 <resources>
 
     <string name="error_generic">An error occurred.</string>
@@ -352,6 +369,7 @@
     <string name="notification_update_description">Notifications when posts you\'ve interacted with are edited</string>
     <string name="notification_report_name">Reports</string>
     <string name="notification_report_description">Notifications about moderation reports</string>
+    <string name="notification_unknown_name">Unknown</string>
 
     <string name="notification_mention_format">%s mentioned you</string>
     <string name="notification_summary_large">%1$s, %2$s, %3$s and %4$d others</string>
@@ -749,4 +767,21 @@
     <string name="accessibility_talking_about_tag">%1$d people are talking about hashtag %2$s</string>
     <string name="total_usage">Total usage</string>
     <string name="total_accounts">Total accounts</string>
+
+    <!-- User friendly error messages for different network errors -->
+    <string name="socket_timeout_exception">Contacting your server took too long</string>
+
+    <!-- Error messages, displayed in snackbars, when something failed -->
+    <string name="ui_error_unknown">unknown reason</string>
+    <string name="ui_error_bookmark">Bookmarking post failed: %s</string>
+    <string name="ui_error_clear_notifications">Clearing notifications failed: %s</string>
+    <string name="ui_error_favourite">Favoriting post failed: %s</string>
+    <string name="ui_error_reblog">Boosting post failed: %s</string>
+    <string name="ui_error_vote">Voting in poll failed: %s</string>
+    <string name="ui_error_accept_follow_request">Accepting follow request failed: %s</string>
+    <string name="ui_error_reject_follow_request">Rejecting follow request failed: %s</string>
+
+    <!-- Success messages, displayed in snackbars, when an action succeeded -->
+    <string name="ui_success_accepted_follow_request">Follow request accepted</string>
+    <string name="ui_success_rejected_follow_request">Follow request blocked</string>
 </resources>

+ 83 - 65
app/src/test/java/com/keylesspalace/tusky/FilterTest.kt

@@ -1,3 +1,20 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
 package com.keylesspalace.tusky
 
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -16,7 +33,6 @@ import org.junit.runner.RunWith
 import org.mockito.kotlin.mock
 import org.robolectric.annotation.Config
 import java.time.Instant
-import java.util.ArrayList
 import java.util.Date
 
 @Config(sdk = [28])
@@ -243,69 +259,71 @@ class FilterTest {
         assert(updatedDuration != null && updatedDuration > (expiresInSeconds - 60))
     }
 
-    private fun mockStatus(
-        content: String = "",
-        spoilerText: String = "",
-        pollOptions: List<String>? = null,
-        attachmentsDescriptions: List<String>? = null
-    ): Status {
-        return Status(
-            id = "123",
-            url = "https://mastodon.social/@Tusky/100571663297225812",
-            account = mock(),
-            inReplyToId = null,
-            inReplyToAccountId = null,
-            reblog = null,
-            content = content,
-            createdAt = Date(),
-            editedAt = null,
-            emojis = emptyList(),
-            reblogsCount = 0,
-            favouritesCount = 0,
-            repliesCount = 0,
-            reblogged = false,
-            favourited = false,
-            bookmarked = false,
-            sensitive = false,
-            spoilerText = spoilerText,
-            visibility = Status.Visibility.PUBLIC,
-            attachments = if (attachmentsDescriptions != null) {
-                ArrayList(
-                    attachmentsDescriptions.map {
-                        Attachment(
-                            id = "1234",
-                            url = "",
-                            previewUrl = null,
-                            meta = null,
-                            type = Attachment.Type.IMAGE,
-                            description = it,
-                            blurhash = null
-                        )
-                    }
-                )
-            } else arrayListOf(),
-            mentions = listOf(),
-            tags = 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,
-                    ownVotes = null
-                )
-            } else null,
-            card = null,
-            language = null,
-        )
+    companion object {
+        fun mockStatus(
+            content: String = "",
+            spoilerText: String = "",
+            pollOptions: List<String>? = null,
+            attachmentsDescriptions: List<String>? = null
+        ): Status {
+            return Status(
+                id = "123",
+                url = "https://mastodon.social/@Tusky/100571663297225812",
+                account = mock(),
+                inReplyToId = null,
+                inReplyToAccountId = null,
+                reblog = null,
+                content = content,
+                createdAt = Date(),
+                editedAt = null,
+                emojis = emptyList(),
+                reblogsCount = 0,
+                favouritesCount = 0,
+                repliesCount = 0,
+                reblogged = false,
+                favourited = false,
+                bookmarked = false,
+                sensitive = false,
+                spoilerText = spoilerText,
+                visibility = Status.Visibility.PUBLIC,
+                attachments = if (attachmentsDescriptions != null) {
+                    ArrayList(
+                        attachmentsDescriptions.map {
+                            Attachment(
+                                id = "1234",
+                                url = "",
+                                previewUrl = null,
+                                meta = null,
+                                type = Attachment.Type.IMAGE,
+                                description = it,
+                                blurhash = null
+                            )
+                        }
+                    )
+                } else arrayListOf(),
+                mentions = listOf(),
+                tags = 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,
+                        ownVotes = null
+                    )
+                } else null,
+                card = null,
+                language = null,
+            )
+        }
     }
 }

+ 137 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt

@@ -0,0 +1,137 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.content.SharedPreferences
+import android.os.Looper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.keylesspalace.tusky.appstore.EventHub
+import com.keylesspalace.tusky.db.AccountEntity
+import com.keylesspalace.tusky.db.AccountManager
+import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.usecase.TimelineCases
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import okhttp3.ResponseBody
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Before
+import org.junit.Rule
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import retrofit2.HttpException
+import retrofit2.Response
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainCoroutineRule constructor(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
+    override fun starting(description: Description) {
+        super.starting(description)
+        Dispatchers.setMain(dispatcher)
+    }
+
+    override fun finished(description: Description) {
+        super.finished(description)
+        Dispatchers.resetMain()
+    }
+}
+
+@Config(sdk = [28])
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+abstract class NotificationsViewModelTestBase {
+    protected lateinit var notificationsRepository: NotificationsRepository
+    protected lateinit var sharedPreferencesMap: MutableMap<String, Boolean>
+    protected lateinit var sharedPreferences: SharedPreferences
+    protected lateinit var accountManager: AccountManager
+    protected lateinit var timelineCases: TimelineCases
+    protected lateinit var eventHub: EventHub
+    protected lateinit var viewModel: NotificationsViewModel
+
+    /** Empty success response, for API calls that return one */
+    protected var emptySuccess = Response.success("".toResponseBody())
+
+    /** Empty error response, for API calls that return one */
+    protected var emptyError: Response<ResponseBody> = Response.error(404, "".toResponseBody())
+
+    /** Exception to throw when testing errors */
+    protected val httpException = HttpException(emptyError)
+
+    @get:Rule
+    val mainCoroutineRule = MainCoroutineRule()
+
+    @Before
+    fun setup() {
+        shadowOf(Looper.getMainLooper()).idle()
+
+        notificationsRepository = mock()
+
+        // Backing store for sharedPreferences, to allow mutation in tests
+        sharedPreferencesMap = mutableMapOf(
+            PrefKeys.ANIMATE_GIF_AVATARS to false,
+            PrefKeys.ANIMATE_CUSTOM_EMOJIS to false,
+            PrefKeys.ABSOLUTE_TIME_VIEW to false,
+            PrefKeys.SHOW_BOT_OVERLAY to true,
+            PrefKeys.USE_BLURHASH to true,
+            PrefKeys.CONFIRM_REBLOGS to true,
+            PrefKeys.CONFIRM_FAVOURITES to false,
+            PrefKeys.WELLBEING_HIDE_STATS_POSTS to false,
+            PrefKeys.SHOW_NOTIFICATIONS_FILTER to true,
+            PrefKeys.FAB_HIDE to false
+        )
+
+        // Any getBoolean() call looks for the result in sharedPreferencesMap
+        sharedPreferences = mock {
+            on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] }
+        }
+
+        accountManager = mock {
+            on { activeAccount } doReturn AccountEntity(
+                id = 1,
+                domain = "mastodon.test",
+                accessToken = "fakeToken",
+                clientId = "fakeId",
+                clientSecret = "fakeSecret",
+                isActive = true,
+                notificationsFilter = "['follow']",
+                mediaPreviewEnabled = true,
+                alwaysShowSensitiveMedia = true,
+                alwaysOpenSpoiler = true
+            )
+        }
+        eventHub = EventHub()
+        timelineCases = mock()
+
+        viewModel = NotificationsViewModel(
+            notificationsRepository,
+            sharedPreferences,
+            accountManager,
+            timelineCases,
+            eventHub
+        )
+    }
+}

+ 65 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+
+/**
+ * Verify that [ClearNotifications] is handled correctly on receipt:
+ *
+ * - Is the correct [UiSuccess] or [UiError] value emitted?
+ * - Are the correct [NotificationsRepository] functions called, with the correct arguments?
+ *   This is only tested in the success case; if it passed there it must also
+ *   have passed in the error case.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() {
+    @Test
+    fun `clearing notifications succeeds && invalidate the repository`() = runTest {
+        // Given
+        notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptySuccess }
+
+        // When
+        viewModel.accept(FallibleUiAction.ClearNotifications)
+
+        // Then
+        verify(notificationsRepository).clearNotifications()
+        verify(notificationsRepository).invalidate()
+    }
+
+    @Test
+    fun `clearing notifications fails && emits UiError`() = runTest {
+        // Given
+        notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptyError }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(FallibleUiAction.ClearNotifications)
+
+            // Then
+            assertThat(awaitItem()).isInstanceOf(UiError::class.java)
+        }
+    }
+}

+ 66 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt

@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.keylesspalace.tusky.db.AccountEntity
+import com.keylesspalace.tusky.entity.Notification
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.verify
+
+/**
+ * Verify that [ApplyFilter] is handled correctly on receipt:
+ *
+ * - Is the [UiState] updated correctly?
+ * - Are the correct [AccountManager] functions called, with the correct arguments?
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestFilter : NotificationsViewModelTestBase() {
+
+    @Test
+    fun `should load initial filter from active account`() = runTest {
+        viewModel.uiState.test {
+            assertThat(awaitItem().activeFilter)
+                .containsExactlyElementsIn(setOf(Notification.Type.FOLLOW))
+        }
+    }
+
+    @Test
+    fun `should save filter to active account && update state`() = runTest {
+        viewModel.uiState.test {
+            // When
+            viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG)))
+
+            // Then
+            // - filter saved to active account
+            argumentCaptor<AccountEntity>().apply {
+                verify(accountManager).saveAccount(capture())
+                assertThat(this.lastValue.notificationsFilter)
+                    .isEqualTo("[\"reblog\"]")
+            }
+
+            // - filter updated in uiState
+            assertThat(expectMostRecentItem().activeFilter)
+                .containsExactlyElementsIn(setOf(Notification.Type.REBLOG))
+        }
+    }
+}

+ 144 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt

@@ -0,0 +1,144 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.keylesspalace.tusky.entity.Relationship
+import io.reactivex.rxjava3.core.Single
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+
+/**
+ * Verify that [NotificationAction] are handled correctly on receipt:
+ *
+ * - Is the correct [UiSuccess] or [UiError] value emitted?
+ * - Is the correct [TimelineCases] function called, with the correct arguments?
+ *   This is only tested in the success case; if it passed there it must also
+ *   have passed in the error case.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestNotificationAction : NotificationsViewModelTestBase() {
+    /** Dummy relationship */
+    private val relationship = Relationship(
+        // Nothing special about these values, it's just to have something to return
+        "1234",
+        following = true,
+        followedBy = true,
+        blocking = false,
+        muting = false,
+        mutingNotifications = false,
+        requested = false,
+        showingReblogs = false,
+        subscribing = null,
+        blockingDomain = false,
+        note = null,
+        notifying = null
+    )
+
+    /** Action to accept a follow request */
+    private val acceptAction = NotificationAction.AcceptFollowRequest("1234")
+
+    /** Action to reject a follow request */
+    private val rejectAction = NotificationAction.RejectFollowRequest("1234")
+
+    @Test
+    fun `accepting follow request succeeds && emits UiSuccess`() = runTest {
+        // Given
+        timelineCases.stub {
+            onBlocking { acceptFollowRequest(any()) } doReturn Single.just(relationship)
+        }
+
+        viewModel.uiSuccess.test {
+            // When
+            viewModel.accept(acceptAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
+            assertThat((item as NotificationActionSuccess).action).isEqualTo(acceptAction)
+        }
+
+        // Then
+        argumentCaptor<String>().apply {
+            verify(timelineCases).acceptFollowRequest(capture())
+            assertThat(this.lastValue).isEqualTo("1234")
+        }
+    }
+
+    @Test
+    fun `accepting follow request fails && emits UiError`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { acceptFollowRequest(any()) } doThrow httpException }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(acceptAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(UiError.AcceptFollowRequest::class.java)
+            assertThat(item.action).isEqualTo(acceptAction)
+        }
+    }
+
+    @Test
+    fun `rejecting follow request succeeds && emits UiSuccess`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doReturn Single.just(relationship) }
+
+        viewModel.uiSuccess.test {
+            // When
+            viewModel.accept(rejectAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
+            assertThat((item as NotificationActionSuccess).action).isEqualTo(rejectAction)
+        }
+
+        // Then
+        argumentCaptor<String>().apply {
+            verify(timelineCases).rejectFollowRequest(capture())
+            assertThat(this.lastValue).isEqualTo("1234")
+        }
+    }
+
+    @Test
+    fun `rejecting follow request fails && emits UiError`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doThrow httpException }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(rejectAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(UiError.RejectFollowRequest::class.java)
+            assertThat(item.action).isEqualTo(rejectAction)
+        }
+    }
+}

+ 227 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt

@@ -0,0 +1,227 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.keylesspalace.tusky.FilterTest.Companion.mockStatus
+import com.keylesspalace.tusky.viewdata.StatusViewData
+import io.reactivex.rxjava3.core.Single
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+
+/**
+ * Verify that [StatusAction] are handled correctly on receipt:
+ *
+ * - Is the correct [UiSuccess] or [UiError] value emitted?
+ * - Is the correct [TimelineCases] function called, with the correct arguments?
+ *   This is only tested in the success case; if it passed there it must also
+ *   have passed in the error case.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() {
+    private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
+    private val statusViewData = StatusViewData.Concrete(
+        status = status,
+        isExpanded = true,
+        isShowingContent = false,
+        isCollapsed = false
+    )
+
+    /** Action to bookmark a status */
+    private val bookmarkAction = StatusAction.Bookmark(true, statusViewData)
+
+    /** Action to favourite a status */
+    private val favouriteAction = StatusAction.Favourite(true, statusViewData)
+
+    /** Action to reblog a status */
+    private val reblogAction = StatusAction.Reblog(true, statusViewData)
+
+    /** Action to vote in a poll */
+    private val voteInPollAction = StatusAction.VoteInPoll(
+        poll = status.poll!!,
+        choices = listOf(1, 0, 0),
+        statusViewData
+    )
+
+    /** Captors for status ID and state arguments */
+    private val id = argumentCaptor<String>()
+    private val state = argumentCaptor<Boolean>()
+
+    @Test
+    fun `bookmark succeeds && emits UiSuccess`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn Single.just(status) }
+
+        viewModel.uiSuccess.test {
+            // When
+            viewModel.accept(bookmarkAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java)
+            assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
+        }
+
+        // Then
+        verify(timelineCases).bookmark(id.capture(), state.capture())
+        assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
+        assertThat(state.firstValue).isEqualTo(true)
+    }
+
+    @Test
+    fun `bookmark fails && emits UiError`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(bookmarkAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(UiError.Bookmark::class.java)
+            assertThat(item.action).isEqualTo(bookmarkAction)
+        }
+    }
+
+    @Test
+    fun `favourite succeeds && emits UiSuccess`() = runTest {
+        // Given
+        timelineCases.stub {
+            onBlocking { favourite(any(), any()) } doReturn Single.just(status)
+        }
+
+        viewModel.uiSuccess.test {
+            // When
+            viewModel.accept(favouriteAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java)
+            assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
+        }
+
+        // Then
+        verify(timelineCases).favourite(id.capture(), state.capture())
+        assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
+        assertThat(state.firstValue).isEqualTo(true)
+    }
+
+    @Test
+    fun `favourite fails && emits UiError`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(favouriteAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(UiError.Favourite::class.java)
+            assertThat(item.action).isEqualTo(favouriteAction)
+        }
+    }
+
+    @Test
+    fun `reblog succeeds && emits UiSuccess`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn Single.just(status) }
+
+        viewModel.uiSuccess.test {
+            // When
+            viewModel.accept(reblogAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java)
+            assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
+        }
+
+        // Then
+        verify(timelineCases).reblog(id.capture(), state.capture())
+        assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
+        assertThat(state.firstValue).isEqualTo(true)
+    }
+
+    @Test
+    fun `reblog fails && emits UiError`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(reblogAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(UiError.Reblog::class.java)
+            assertThat(item.action).isEqualTo(reblogAction)
+        }
+    }
+
+    @Test
+    fun `voteinpoll succeeds && emits UiSuccess`() = runTest {
+        // Given
+        timelineCases.stub {
+            onBlocking { voteInPoll(any(), any(), any()) } doReturn Single.just(status.poll!!)
+        }
+
+        viewModel.uiSuccess.test {
+            // When
+            viewModel.accept(voteInPollAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java)
+            assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
+        }
+
+        // Then
+        val pollId = argumentCaptor<String>()
+        val choices = argumentCaptor<List<Int>>()
+        verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture())
+        assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
+        assertThat(pollId.firstValue).isEqualTo(status.poll!!.id)
+        assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices)
+    }
+
+    @Test
+    fun `voteinpoll fails && emits UiError`() = runTest {
+        // Given
+        timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
+
+        viewModel.uiError.test {
+            // When
+            viewModel.accept(voteInPollAction)
+
+            // Then
+            val item = awaitItem()
+            assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java)
+            assertThat(item.action).isEqualTo(voteInPollAction)
+        }
+    }
+}

+ 102 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt

@@ -0,0 +1,102 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
+import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.util.CardViewMode
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+/**
+ * Verify that [StatusDisplayOptions] are handled correctly.
+ *
+ * - Is the initial value taken from values in sharedPreferences and account?
+ * - Does the make() function correctly use an updated preference?
+ * - Is the correct update emitted when a relevant preference changes?
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() {
+
+    private val defaultStatusDisplayOptions = StatusDisplayOptions(
+        animateAvatars = false,
+        mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase
+        useAbsoluteTime = false,
+        showBotOverlay = true,
+        useBlurhash = true,
+        cardViewMode = CardViewMode.NONE,
+        confirmReblogs = true,
+        confirmFavourites = false,
+        hideStats = false,
+        animateEmojis = false,
+        showSensitiveMedia = true, // setting in NotificationsViewModelTestBase
+        openSpoiler = true // setting in NotificationsViewModelTestBase
+    )
+
+    @Test
+    fun `initial settings are from sharedPreferences and activeAccount`() = runTest {
+        viewModel.statusDisplayOptions.test {
+            val item = awaitItem()
+            assertThat(item).isEqualTo(defaultStatusDisplayOptions)
+        }
+    }
+
+    @Test
+    fun `make() uses updated preference`() = runTest {
+        // Prior, should be false
+        assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse()
+
+        // Given; just a change to one preferences
+        sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
+
+        // When
+        val updatedOptions = defaultStatusDisplayOptions.make(
+            sharedPreferences,
+            PrefKeys.ANIMATE_GIF_AVATARS,
+            accountManager.activeAccount!!
+        )
+
+        // Then, should be true
+        assertThat(updatedOptions.animateAvatars).isTrue()
+    }
+
+    @Test
+    fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest {
+        // Prior, should be false
+        viewModel.statusDisplayOptions.test {
+            val item = expectMostRecentItem()
+            assertThat(item.animateAvatars).isFalse()
+        }
+
+        // Given
+        sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
+
+        // When
+        eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS))
+
+        // Then, should be true
+        viewModel.statusDisplayOptions.test {
+            val item = expectMostRecentItem()
+            assertThat(item.animateAvatars).isTrue()
+        }
+    }
+}

+ 88 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt

@@ -0,0 +1,88 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.settings.PrefKeys
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+/**
+ * Verify that [UiState] is handled correctly.
+ *
+ * - Is the initial value taken from values in sharedPreferences and account?
+ * - Is the correct update emitted when a relevant preference changes?
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() {
+
+    private val initialUiState = UiState(
+        activeFilter = setOf(Notification.Type.FOLLOW),
+        showFilterOptions = true,
+        showFabWhileScrolling = true
+    )
+
+    @Test
+    fun `should load initial filter from active account`() = runTest {
+        viewModel.uiState.test {
+            assertThat(expectMostRecentItem()).isEqualTo(initialUiState)
+        }
+    }
+
+    @Test
+    fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest {
+        // Prior
+        viewModel.uiState.test {
+            assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue()
+        }
+
+        // Given
+        sharedPreferencesMap[PrefKeys.FAB_HIDE] = true
+
+        // When
+        eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE))
+
+        // Then
+        viewModel.uiState.test {
+            assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse()
+        }
+    }
+
+    @Test
+    fun `showFilterOptions depends on SHOW_NOTIFICATIONS_FILTER preference`() = runTest {
+        // Prior
+        viewModel.uiState.test {
+            assertThat(expectMostRecentItem().showFilterOptions).isTrue()
+        }
+
+        // Given
+        sharedPreferencesMap[PrefKeys.SHOW_NOTIFICATIONS_FILTER] = false
+
+        // When
+        eventHub.dispatch(PreferenceChangedEvent(PrefKeys.SHOW_NOTIFICATIONS_FILTER))
+
+        // Then
+        viewModel.uiState.test {
+            assertThat(expectMostRecentItem().showFilterOptions).isFalse()
+        }
+    }
+}

+ 43 - 0
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 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 <http://www.gnu.org/licenses>.
+ */
+
+package com.keylesspalace.tusky.components.notifications
+
+import com.google.common.truth.Truth.assertThat
+import com.keylesspalace.tusky.db.AccountEntity
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.verify
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() {
+
+    @Test
+    fun `should save notification ID to active account`() = runTest {
+        argumentCaptor<AccountEntity>().apply {
+            // When
+            viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
+
+            // Then
+            verify(accountManager).saveAccount(capture())
+            assertThat(this.lastValue.lastNotificationId)
+                .isEqualTo("1234")
+        }
+    }
+}

+ 615 - 0
doc/ViewModelInterface.md

@@ -0,0 +1,615 @@
+# View model interface
+
+## Synopsis
+
+This document explains how data flows between the view model and the UI it
+is serving (either an `Activity` or `Fragment`).
+
+> Note: At the time of writing this is correct for `NotificationsViewModel`
+> and `NotificationsFragment`. Other components will be updated over time.
+
+After reading this document you should understand:
+
+- How user actions in the UI are communicated to the view model
+- How changes in the view model are communicated to the UI
+
+Before reading this document you should:
+
+- Understand Kotlin flows
+- Read [Guide to app architecture / UI layer](https://developer.android.com/topic/architecture/ui-layer) 
+
+## Action and UiState flows
+
+### The basics
+
+Every action between the user and application can be reduced to the following:
+
+```mermaid
+sequenceDiagram
+    actor user as User
+    participant ui as Fragment
+    participant vm as View Model
+    user->>+ui: Performs UI action
+    ui->>+vm: Sends action
+    vm->>-ui: Sends new UI state
+    ui->>ui: Updates visible UI
+    ui-->>-user: Observes changes
+```
+
+In this model, actions always flow from left to right. The user tells
+the fragment to do something, then te fragment tells the view model to do
+something.
+
+The view model does **not** tell the fragment to do something.
+
+State always flows from right to left. The view model tells the fragment
+"Here's the new state, it up to you how to display it."
+
+Not shown on this diagram, but implicit, is these actions are asynchronous,
+and the view model may be making one or more requests to other components to
+gather the data to use for the new UI state.
+
+Rather than modelling this transfer of data as function calls, and by passing
+callback functions from place to place they can be modelled as Kotlin flows
+between the Fragment and View Model.
+
+For example:
+
+1. The View Model creates two flows and exposes them to the Fragment.
+
+```kotlin
+// In the View Model
+data class UiAction(val action: String) { ... }
+
+data class UiState(...) { ... }
+
+val actionFlow = MutableSharedFlow<UiAction>()
+val uiStateFlow = StateFlow<UiState>()
+
+init {
+    // ...
+
+    viewModelScope.launch {
+        actionFlow
+            .collect {
+                // Do work
+                // ... work is complete
+
+                // Update UI state
+                uiStateFlow.emit(uiStatFlow.value.update { ... })
+            }
+    }
+
+    // ...
+}
+```
+
+2. The fragment collects from `uiStateFlow`, and updates the visible UI,
+and emits new `UiAction` objects in to `actionFlow` in response to the
+user interacting with the UI.
+
+```kotlin
+// In the Fragment
+fun onViewCreated(...) {
+    // ...
+
+    binding.button.setOnClickListener {
+        // Won't work, see section "Accepting user actions from the UI" for why
+        viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
+    }
+
+    lifecycleScope.launch {
+        viewModel.uiStateFlow.collectLatest { uiState ->
+            updateUiWithState(uiState)
+        }
+    }
+
+    // ...
+}
+```
+
+This is a good start, but it can be me significantly improved.
+
+### Model actions with sealed classes
+
+The prototypical example in the previous section suggested the
+`UiAction` could be modelled as
+
+```kotlin
+data class UiAction(val action: String) { ... }
+```
+
+This is not great.
+
+- It's stringly-typed, with opportunity for run time errors
+- Trying to store all possible UI actions in a single type will lead
+  to a plethora of different properties, only some of which are valid
+  for a given action.
+
+These problems can be solved by making `UiAction` a sealed class, and
+defining subclasses, one per action.
+
+In the case of `NotificationsFragment` the actions the user can take in
+the UI are:
+
+- Apply a filter to the set of notifications
+- Clear the current set of notifications
+- Save the ID of the currently visible notification in the list
+
+> NOTE: The user can also interact with items in the list of the
+> notifications.
+> 
+> That is handled a little differently because of how code outside
+> `NotificationsFragment` is currently written. It will be adjusted at
+> a later time.
+
+That becomes:
+
+```kotlin
+// In the View Model
+sealed class UiAction {
+    data class ApplyFilter(val filter: Set<Filter>) : UiAction()
+    object ClearNotifications : UiAction()
+    data class SaveVisibleId(val visibleId: String) : UiAction()
+}
+```
+
+This has multiple benefits:
+
+- The actions the view model can act on are defined in a single place
+- Each action clearly describes the information it carries with it
+- Each action is strongly typed; it is impossible to create an action
+  of the wrong type
+- As a sealed class, using the `when` statement to process actions gives
+  us compile-time guarantees all actions are handled
+
+In addition, the view model can spawn multiple coroutines to process
+the different actions, by filtering out actions dependent on their type,
+and using other convenience methods on flows. For example:
+
+```kotlin
+// In the View Model
+val actionFlow = MutableSharedFlow<UiAction>() // As before
+
+init {
+    // ...
+    
+    handleApplyFilter()
+    handleClearNotifications()
+    handleSaveVisibleId()
+    
+    // ...
+}
+
+fun handleApplyFilter() = viewModelScope.launch {
+    actionFlow
+        .filterIsInstance<UiAction.ApplyFilter>()
+        .distinctUntilChanged()
+        .collect { action ->
+            // Apply the filter, update state
+        }
+}
+
+fun handleClearNotifications() = viewModelScope.launch {
+    actionFlow
+        .filterIsInstance<UiAction.ClearNotifications>()
+        .distinctUntilChanged()
+        .collect { action -> 
+            // Clear notifications, update state
+        }
+}
+
+fun handleSaveVisibleId() = viewModelScope.launch {
+    actionFlow
+        .filterIsInstance<UiAction.SaveVisibleId>()
+        .distinctUntilChanged()
+        .collect { action ->
+            // Save the ID, no need to update state
+        }
+}
+```
+
+Each of those runs in separate coroutines and ignores duplicate events.
+
+### Accepting user actions from the UI
+
+Example code earlier had this snippet, which does not work.
+
+```kotlin
+// In the Fragment
+binding.button.setOnClickListener {
+    // Won't work, see section "Accepting user actions from the UI" for why
+    viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
+}
+```
+
+This fails because `emit()` is a `suspend fun`, so it must be called from a
+coroutine scope.
+
+To fix this, provide a function or property in the view model that accepts
+`UiAction` and emits them in `actionFlow` under the view model's scope.
+
+```kotlin
+// In the View Model
+val accept: (UiAction) -> Unit = { action ->
+    viewModelScope.launch { actionFlow.emit(action)}
+}
+```
+
+When the Fragment wants to send a `UiAction` to the view model it:
+
+```kotlin
+// In the Fragment
+binding.button.setOnClickListener {
+    viewModel.accept(UiAction.ClearNotifications)
+}
+```
+
+### Model the difference between fallible and infallible actions
+
+An infallible action either cannot fail, or, can fail but there are no
+user-visible changes to the UI.
+
+Conversely, a fallible action can fail and the user should be notified.
+
+I've found it helpful to distinguish between the two at the type level, as
+it simplifies error handling in the Fragment.
+
+So the actions in `NotificationFragment` are modelled as:
+
+```kotlin
+// In the View Model
+sealed class UiAction
+
+sealed class FallibleUiAction : UiAction() {
+    // Actions that can fail are modelled here
+    // ...
+}
+
+sealed class InfallibleUiAction : UiAction() {
+    // Actions that cannot fail are modelled here
+    // ...
+}
+```
+
+### Additional `UiAction` subclasses
+
+It can be useful to have a deeper `UiAction` class hierarchy, as filtering
+flows by the class of item in the flow is straightforward.
+
+`NotificationsViewModel` splits the fallible actions the user can take as
+operating on three different parts of the UI:
+
+- Everything not the list of notifications
+- Notifications in the list of notifications
+- Statuses in the list of notifications
+
+Those last two are modelled as:
+
+```kotlin
+// In the View Model
+sealed class NotificationAction : FallibleUiAction() {
+    // subclasses here
+}
+
+sealed class StatusAction(
+    open val statusViewData: StatusViewData.Concrete
+) : FallibleUiAction() {
+    // subclasses here
+}
+```
+
+Separate handling for actions on notifications and statuses is then achieved
+with code like:
+
+```kotlin
+viewModelScope.launch {
+    uiAction.filterIsInstance<NotificationAction>()
+        .collect { action ->
+            // Process notification actions here
+        }
+}
+
+viewModelScope.launch {
+    uiAction.filterIsInstance<StatusAction>()
+        .collect { action ->
+            // Process status actions where
+        }
+}
+```
+
+At the time of writing the UI action hierarchy for `NotificationsViewModel`
+is:
+
+```mermaid
+classDiagram
+  direction LR
+  UiAction <|-- InfallibleUiAction
+  InfallibleUiAction <|-- SaveVisibleId
+  InfallibleUiAction <|-- ApplyFilter
+  UiAction <|-- FallibleUiAction
+  FallibleUiAction <|-- ClearNotifications
+  FallibleUiAction <|-- NotificationAction
+  NotificationAction <|-- AcceptFollowRequest
+  NotificationAction <|-- RejectFollowRequest
+  FallibleUiAction <|-- StatusAction
+  StatusAction <|-- Bookmark
+  StatusAction <|-- Favourite
+  StatusAction <|-- Reblog
+  StatusAction <|-- VoteInPoll
+  
+```
+
+### Multiple output flows
+
+So far the UI has been modelled as a single output flow of a single `UiState`
+type.
+
+For simple UIs that can be sufficient. As the UI gets more complex it
+can be helpful to separate these in to different flows.
+
+In some cases the Android framework requires you to do this. For
+example, the flow of `PagingData` in to the adapter is provided and
+managed by the `PagingData` class. You should not attempt to reassign
+it or update it during normal operation.
+
+Similarly, `RecyclerView.Adapter` provides its own `loadStateFlow`, which
+communicates information about the loading state of data in to the adapter.
+
+For `NotificationsViewModel` I have found it helpful to provide flows to
+separate the following types
+
+- `PagingData` in to the adapter
+- `UiState`, representing UI state *outside* the main `RecyclerView`
+- `StatusDisplayOptions`, representing the user's preferences for how
+  all statuses should be displayed
+- `UiSuccess`, representing transient notifications about a
+  fallible action succeeding
+- `UiError`, representing transient notifications about a fallible action
+  failing
+
+There are separated this way to roughly match how the Fragment will want
+to process them.
+
+- `PagingData` is handed to the adapter and not modified by the Fragment
+- `UiState` is generally updated no matter what has changed.
+- `StatusDisplayOptions` is handled by rebinding all visible items in
+  the list, without disturbing the rest of the UI
+- `UiSuccess` show a brief snackbar without disturbing the rest
+  of the UI
+- `UiError` show a fixed snackbar with a "Retry" option
+
+They also have different statefulness requirements, which makes separating
+them in to different flows a sensible approach.
+
+`PagingData`, `UiState`, and `StatusDisplayOptions` are stateful -- if the
+Fragment disconnects from the flow and then reconnects (e.g., because of a
+configuration change) the Fragment should receive the most recent state of
+each of these.
+
+`UiSuccess` and `UiError` are not stateful. The success and error messages are
+transient; if one has been shown, and there is a subsequent configuration
+change the user should not see the success or error message again.
+
+### Modelling success and failure for fallible actions
+
+A fallible action should have models capturing success and failure
+information, and be communicated to the UI.
+
+> Note: Infallible actions, by definition, neither succeed or fail, so
+> there is no need to model those states for them.
+
+Suppose the user has clicked on the "bookmark" button on a status,
+sending a `UiAction.FallibleAction.StatusAction.Bookmark(...)` to the
+view model.
+
+The view model processes the action, and is successful.
+
+To signal this back to the UI it emits a `UiSuccess` subclass for the action's
+type in to the `uiSuccess` flow, and includes the original action request.
+
+You can read this as the `action` in the `UiAction` is a message from the
+Fragment saying "Here is the action I want to be performed" and the `action`
+in `UiSuccess` is the View Model saying "Here is the action that was carried
+out."
+
+Unsurprisingly, this is modelled with a `UiSuccess` class, and per-action
+subclasses.
+
+Failures are modelled similarly, with a `UiError` class. However, details
+about the error are included, as well as the original action.
+
+So each fallible action has three associated classes; one for the action,
+one to represent the action succeeding, and one to represent the action
+failing.
+
+For the single "bookmark a status" action the code for its three classes
+looks like this:
+
+```kotlin
+// In the View Model
+sealed class StatusAction(
+    open val statusViewData: StatusViewData.Concrete
+) : FallibleUiAction() {
+    data class Bookmark(
+        val state: Boolean,
+        override val statusViewData: StatusViewData.Concrete
+    ) : StatusAction(statusViewData)
+  
+    // ... other actions here
+}
+
+sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () {
+    data class Bookmark(override val action: StatusAction.Bookmark) :
+        StatusActionSuccess(action)
+  
+    // ... other action successes here
+  
+    companion object {
+        fun from (action: StatusAction) = when (action) {
+            is StatusAction.Bookmark -> Bookmark(action)
+            // ... other actions here
+        }
+    }
+}
+
+sealed class UiError(
+    open val exception: Exception,
+    @StringRes val message: Int,
+    open val action: UiAction? = null
+) {
+    data class Bookmark(
+      override val exception: Exception,
+      override val action: StatusAction.Bookmark
+    ) : UiError(exception, R.string.ui_error_bookmark, action)
+  
+    // ... other action errors here
+
+    companion object {
+        fun make(exception: Exception, action: FallibleUiAction) = when (action) {
+            is StatusAction.Bookmark -> Bookmark(exception, action)
+            // other actions here
+      }
+    }
+}
+```
+
+> Note: I haven't found it necessary to create subclasses for `UiError`, as
+> all fallible errors (so far) are handled identically. This may change in
+> the future.
+
+Receiving status actions in the view model (from the `uiAction` flow) is then:
+
+```kotlin
+// In the View Model
+viewModelScope.launch {
+    uiAction.filterIsInstance<StatusAction>()
+      .collect { action ->
+          try {
+              when (action) {
+                  is StatusAction.Bookmark -> {
+                     // Process the request
+                  }
+                  // Other action types handled here
+              }
+              uiSuccess.emit(StatusActionSuccess.from(action))
+          } catch (e: Exception) {
+              uiError.emit(UiError.make(e, action))
+          }
+      }
+}
+```
+
+Basic success handling in the fragment would be:
+
+```kotlin
+// In the Fragment
+lifecycleScope.launch {
+    // Show a generic message when an action succeeds
+    this.launch {
+        viewModel.uiSuccess.collect {
+            Snackbar.make(binding.root, "Success!", LENGTH_SHORT).show()
+        }
+    }
+}
+```
+
+In practice it is more complicated, with different actions depending on the
+type of success.
+
+Basic error handling in the fragment would be:
+
+```kotlin
+lifecycleScope.launch {
+    // Show a specific error when an action fails
+    this.launch {
+        viewModel.uiError.collect { error ->
+            SnackBar.make(
+                binding.root,
+                getString(error.message),
+                LENGTH_LONG
+            ).show()
+        }
+    }
+}
+```
+
+### Supporting "retry" semantics
+
+This approach has an extremely helpful benefit. By including the original
+action in the `UiError` response, implementing a "retry" function is as
+simple as re-sending the original action (included in the error) back to
+the view model.
+
+```kotlin
+lifecycleScope.launch {
+    // Show a specific error when an action fails. Provide a "Retry" option
+    // on the snackbar, and re-send the original action to retry.
+    this.launch {
+        viewModel.uiError.collect { error ->
+            val snackbar = SnackBar.make(
+                binding.root,
+                getString(error.message),
+                LENGTH_LONG
+            )
+            error.action?.let { action -> 
+                snackbar.setAction("Retry") { viewModel.accept(action) }
+            }
+            snackbar.show()
+        }
+    }
+}
+```
+
+### Updated sequence diagram
+
+```mermaid
+sequenceDiagram
+    actor user as User
+    participant ui as Fragment
+    participant vm as View Model
+    user->>ui: Performs UI action
+    activate ui
+    ui->>+vm: viewModel.accept(UiAction.*())
+    deactivate ui
+    vm->>vm: Perform action
+    alt Update UI state?
+      vm->>vm: emit(UiState(...))
+      vm-->>ui: UiState(...)
+      activate ui
+      ui->>ui: collect UiState, update UI
+      deactivate ui
+      
+    else Update StatusDisplayOptions?
+      vm->>vm: emit(StatusDisplayOptions(...))
+      vm-->>ui: StatusDisplayOption(...)
+      activate ui
+      ui->>ui: collect StatusDisplayOptions, rebind list items
+      deactivate ui
+
+    else Successful fallible action
+      vm->>vm: emit(UiSuccess(...))
+      vm-->>ui: UiSuccess(...)
+      activate ui
+      ui->>ui: collect UiSuccess, show snackbar
+      deactivate ui
+
+    else Failed fallible action
+      vm->>vm: emit(UiError(...))
+      vm-->>ui: UiError(...)
+      activate ui
+      deactivate vm
+      ui->>ui: collect UiError, show snackbar with retry
+      deactivate ui
+      user->>ui: Presses "Retry"
+      activate ui
+      ui->>vm: viewModel.accept(error.action)
+      deactivate ui
+      activate vm
+      vm->>vm: Perform action, emit response...
+      deactivate vm 
+    end
+    note over ui,vm: Type of UI change depends on type of object emitted<br>UiState, StatusDisplayOptions, UiSuccess, UiError
+      
+    ui-->>user: Observes changes
+```

+ 4 - 0
gradle/libs.versions.toml

@@ -47,6 +47,8 @@ rxjava3 = "3.1.6"
 rxkotlin3 = "3.0.1"
 photoview = "2.3.0"
 sparkbutton = "4.1.0"
+truth = "1.1.3"
+turbine = "0.12.1"
 unified-push = "2.1.1"
 
 [plugins]
@@ -129,6 +131,8 @@ rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rx
 rxjava3-core = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava3" }
 rxjava3-kotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin3" }
 sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "sparkbutton" }
+truth = { module = "com.google.truth:truth", version.ref = "truth" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
 unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" }
 
 [bundles]