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>
This commit is contained in:
Nik Clayton 2023-03-10 20:12:33 +01:00 committed by GitHub
commit 4d401c7878
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 4460 additions and 2262 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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