Tusky-App-Android/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt

682 lines
28 KiB
Kotlin

/*
* 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()
}
}