ff8dd37855
* Replace "warn"-filtered posts in timelines and thread view with placeholders * Adapt hashtag muting interface * Rework filter UI * Add icon for account preferences * Clean up UI * WIP: Use chips instead of a list. Adjust padding * Scroll the filter edit activity Nested scrolling views (e.g., an activity that scrolls with an embedded list that also scrolls) can be difficult UI. Since the list of contexts is fixed, replace it with a fixed collection of switches, so there's no need to scroll the list. Since the list of actions is only two (warn, hide), and are mutually exclusive, replace the spinner with two radio buttons. Use the accent colour and title styles on the different heading titles in the layout, to match the presentation in Preferences. Add an explicit "Cancel" button. The layout is a straightforward LinearLayout, so use that instead of ConstraintLayout, and remove some unncessary IDs. Update EditFilterActivity to handle the new layout. * Cleanup * Add more information to the filter list view * First pass on code review comments * Add view model to filters activity * Add view model to edit filters activity * Only use the status wrapper for filtered statuses * Relint --------- Co-authored-by: Nik Clayton <nik@ngo.org.uk>
685 lines
28 KiB
Kotlin
685 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)
|
|
}
|
|
|
|
override fun clearWarningAction(position: Int) {
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|