557 lines
22 KiB
Kotlin
557 lines
22 KiB
Kotlin
/* Copyright 2024 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.DialogInterface
|
|
import android.content.SharedPreferences
|
|
import android.os.Bundle
|
|
import android.view.LayoutInflater
|
|
import android.view.Menu
|
|
import android.view.MenuInflater
|
|
import android.view.MenuItem
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import android.widget.ArrayAdapter
|
|
import android.widget.ListView
|
|
import android.widget.PopupWindow
|
|
import androidx.appcompat.app.AlertDialog
|
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
import androidx.core.view.MenuProvider
|
|
import androidx.fragment.app.viewModels
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.paging.LoadState
|
|
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
|
|
import at.connyduck.calladapter.networkresult.onFailure
|
|
import at.connyduck.sparkbutton.helpers.Utils
|
|
import com.google.android.material.appbar.AppBarLayout
|
|
import com.google.android.material.snackbar.Snackbar
|
|
import com.keylesspalace.tusky.R
|
|
import com.keylesspalace.tusky.appstore.EventHub
|
|
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
|
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
|
|
import com.keylesspalace.tusky.databinding.NotificationsFilterBinding
|
|
import com.keylesspalace.tusky.di.Injectable
|
|
import com.keylesspalace.tusky.di.ViewModelFactory
|
|
import com.keylesspalace.tusky.entity.Notification
|
|
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.settings.PrefKeys
|
|
import com.keylesspalace.tusky.util.CardViewMode
|
|
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
import com.keylesspalace.tusky.util.StatusProvider
|
|
import com.keylesspalace.tusky.util.hide
|
|
import com.keylesspalace.tusky.util.openLink
|
|
import com.keylesspalace.tusky.util.show
|
|
import com.keylesspalace.tusky.util.viewBinding
|
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
|
import javax.inject.Inject
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
import kotlinx.coroutines.launch
|
|
|
|
class NotificationsFragment :
|
|
SFragment(),
|
|
SwipeRefreshLayout.OnRefreshListener,
|
|
StatusActionListener,
|
|
NotificationActionListener,
|
|
AccountActionListener,
|
|
MenuProvider,
|
|
ReselectableFragment,
|
|
Injectable {
|
|
|
|
@Inject
|
|
lateinit var viewModelFactory: ViewModelFactory
|
|
|
|
@Inject
|
|
lateinit var preferences: SharedPreferences
|
|
|
|
@Inject
|
|
lateinit var eventHub: EventHub
|
|
|
|
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
|
|
|
private val viewModel: NotificationsViewModel by viewModels { viewModelFactory }
|
|
|
|
private lateinit var adapter: NotificationsPagingAdapter
|
|
|
|
private var hideFab: Boolean = false
|
|
private var showNotificationsFilterBar: Boolean = true
|
|
private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST
|
|
|
|
/** see [com.keylesspalace.tusky.components.timeline.TimelineFragment] for explanation of the load more mechanism */
|
|
private var loadMorePosition: Int? = null
|
|
private var statusIdBelowLoadMore: String? = null
|
|
|
|
override fun onCreateView(
|
|
inflater: LayoutInflater,
|
|
container: ViewGroup?,
|
|
savedInstanceState: Bundle?
|
|
): View? {
|
|
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
|
|
}
|
|
|
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
val statusDisplayOptions = StatusDisplayOptions(
|
|
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
|
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
|
|
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
|
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
|
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
|
cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) {
|
|
CardViewMode.INDENTED
|
|
} else {
|
|
CardViewMode.NONE
|
|
},
|
|
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
|
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
|
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
|
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
|
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
|
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
|
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
|
)
|
|
|
|
// setup the notifications filter bar
|
|
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
|
|
updateFilterBarVisibility()
|
|
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
|
|
binding.buttonFilter.setOnClickListener { showFilterMenu() }
|
|
|
|
// Setup the SwipeRefreshLayout.
|
|
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
|
|
|
// Setup the RecyclerView.
|
|
binding.recyclerView.setHasFixedSize(true)
|
|
adapter = NotificationsPagingAdapter(
|
|
accountId = accountManager.activeAccount!!.accountId,
|
|
statusListener = this,
|
|
notificationActionListener = this,
|
|
accountActionListener = this,
|
|
statusDisplayOptions = statusDisplayOptions
|
|
)
|
|
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
|
binding.recyclerView.setAccessibilityDelegateCompat(
|
|
ListStatusAccessibilityDelegate(
|
|
binding.recyclerView,
|
|
this,
|
|
StatusProvider { pos: Int ->
|
|
if (pos in 0 until adapter.itemCount) {
|
|
val notification = adapter.peek(pos)
|
|
// We support replies only for now
|
|
if (notification is NotificationViewData.Concrete) {
|
|
return@StatusProvider notification.statusViewData
|
|
} else {
|
|
return@StatusProvider null
|
|
}
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
binding.recyclerView.adapter = adapter
|
|
|
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
|
|
|
binding.recyclerView.addItemDecoration(
|
|
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
|
|
)
|
|
|
|
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
|
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
|
|
|
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
|
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
|
val composeButton = (activity as ActionButtonActivity).actionButton
|
|
if (composeButton != null) {
|
|
if (hideFab) {
|
|
if (dy > 0 && composeButton.isShown) {
|
|
composeButton.hide() // hides the button if we're scrolling down
|
|
} else if (dy < 0 && !composeButton.isShown) {
|
|
composeButton.show() // shows it if we are scrolling up
|
|
}
|
|
} else if (!composeButton.isShown) {
|
|
composeButton.show()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
adapter.addLoadStateListener { loadState ->
|
|
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
|
binding.swipeRefreshLayout.isRefreshing = false
|
|
}
|
|
|
|
binding.statusView.hide()
|
|
binding.progressBar.hide()
|
|
|
|
if (adapter.itemCount == 0) {
|
|
when (loadState.refresh) {
|
|
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)
|
|
}
|
|
}
|
|
is LoadState.Error -> {
|
|
binding.statusView.show()
|
|
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
|
|
}
|
|
is LoadState.Loading -> {
|
|
binding.progressBar.show()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
|
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
|
binding.recyclerView.post {
|
|
if (getView() != null) {
|
|
binding.recyclerView.scrollBy(
|
|
0,
|
|
Utils.dpToPx(binding.recyclerView.context, -30)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (readingOrder == ReadingOrder.OLDEST_FIRST) {
|
|
updateReadingPositionForOldestFirst()
|
|
}
|
|
}
|
|
})
|
|
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
viewModel.notifications.collectLatest { pagingData ->
|
|
adapter.submitData(pagingData)
|
|
}
|
|
}
|
|
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
eventHub.events.collect { event ->
|
|
if (event is PreferenceChangedEvent) {
|
|
onPreferenceChanged(event.preferenceKey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onReselect() {
|
|
if (isAdded) {
|
|
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
|
binding.recyclerView.stopScroll()
|
|
}
|
|
}
|
|
|
|
override fun onRefresh() {
|
|
adapter.refresh()
|
|
}
|
|
|
|
override fun onViewAccount(id: String) {
|
|
super.viewAccount(id)
|
|
}
|
|
|
|
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
|
// not needed, muting via the more menu on statuses is handled in SFragment
|
|
}
|
|
|
|
override fun onBlock(block: Boolean, id: String, position: Int) {
|
|
// not needed, blocking via the more menu on statuses is handled in SFragment
|
|
}
|
|
|
|
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
|
|
val notification = adapter.peek(position) ?: return
|
|
val refreshAdapterFlow = viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
|
|
lifecycleScope.launch {
|
|
refreshAdapterFlow.collect { refreshAdapter ->
|
|
if (refreshAdapter) {
|
|
adapter.refresh()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onViewReport(reportId: String?) {
|
|
requireContext().openLink(
|
|
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
|
|
)
|
|
}
|
|
|
|
override fun onViewTag(tag: String) {
|
|
super.viewTag(tag)
|
|
}
|
|
|
|
override fun onReply(position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
super.reply(status.status)
|
|
}
|
|
|
|
override fun removeItem(position: Int) {
|
|
val notification = adapter.peek(position) ?: return
|
|
viewModel.remove(notification.id)
|
|
}
|
|
|
|
override fun onReblog(reblog: Boolean, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.reblog(reblog, status)
|
|
}
|
|
|
|
override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit
|
|
get() = { translate: Boolean, position: Int ->
|
|
if (translate) {
|
|
onTranslate(position)
|
|
} else {
|
|
onUntranslate(position)
|
|
}
|
|
}
|
|
|
|
private fun onTranslate(position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
lifecycleScope.launch {
|
|
viewModel.translate(status)
|
|
.onFailure {
|
|
Snackbar.make(
|
|
requireView(),
|
|
getString(R.string.ui_error_translate, it.message),
|
|
Snackbar.LENGTH_LONG
|
|
).show()
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onUntranslate(position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.untranslate(status)
|
|
}
|
|
|
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.favorite(favourite, status)
|
|
}
|
|
|
|
override fun onBookmark(bookmark: Boolean, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.bookmark(bookmark, status)
|
|
}
|
|
|
|
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.voteInPoll(choices, status)
|
|
}
|
|
|
|
override fun clearWarningAction(position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.clearWarning(status)
|
|
}
|
|
|
|
override fun onMore(view: View, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
super.more(
|
|
status.status,
|
|
view,
|
|
position,
|
|
(status.translation as? TranslationViewData.Loaded)?.data
|
|
)
|
|
}
|
|
|
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
|
val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return
|
|
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
|
|
}
|
|
|
|
override fun onViewThread(position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull()?.status ?: return
|
|
super.viewThread(status.id, status.url)
|
|
}
|
|
|
|
override fun onOpenReblog(position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
super.openReblog(status.status)
|
|
}
|
|
|
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.changeExpanded(expanded, status)
|
|
}
|
|
|
|
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.changeContentShowing(isShowing, status)
|
|
}
|
|
|
|
override fun onLoadMore(position: Int) {
|
|
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
|
|
loadMorePosition = position
|
|
statusIdBelowLoadMore =
|
|
if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
|
|
viewModel.loadMore(placeholder.id)
|
|
}
|
|
|
|
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
|
viewModel.changeContentCollapsed(isCollapsed, status)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
private fun clearNotifications() {
|
|
viewModel.clearNotifications()
|
|
}
|
|
|
|
private fun showFilterMenu() {
|
|
val notificationTypeList = Notification.Type.visibleTypes.map { type ->
|
|
getString(type.uiString)
|
|
}
|
|
|
|
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList)
|
|
val window = PopupWindow(requireContext())
|
|
val menuBinding = NotificationsFilterBinding.inflate(LayoutInflater.from(requireContext()), binding.root as ViewGroup, false)
|
|
|
|
menuBinding.buttonApply.setOnClickListener {
|
|
val checkedItems = menuBinding.listView.getCheckedItemPositions()
|
|
val excludes = Notification.Type.visibleTypes.filterIndexed { index, _ ->
|
|
!checkedItems[index, false]
|
|
}
|
|
window.dismiss()
|
|
viewModel.updateNotificationFilters(excludes.toSet())
|
|
}
|
|
|
|
menuBinding.listView.setAdapter(adapter)
|
|
menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE)
|
|
|
|
Notification.Type.visibleTypes.forEachIndexed { index, type ->
|
|
menuBinding.listView.setItemChecked(index, !viewModel.filters.value.contains(type))
|
|
}
|
|
|
|
window.setContentView(menuBinding.root)
|
|
window.isFocusable = true
|
|
window.width = ViewGroup.LayoutParams.WRAP_CONTENT
|
|
window.height = ViewGroup.LayoutParams.WRAP_CONTENT
|
|
window.showAsDropDown(binding.buttonFilter)
|
|
}
|
|
|
|
private fun onPreferenceChanged(key: String) {
|
|
when (key) {
|
|
PrefKeys.FAB_HIDE -> {
|
|
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
|
}
|
|
|
|
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
|
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
|
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
|
if (enabled != oldMediaPreviewEnabled) {
|
|
adapter.mediaPreviewEnabled = enabled
|
|
}
|
|
}
|
|
|
|
PrefKeys.SHOW_NOTIFICATIONS_FILTER -> {
|
|
if (isAdded) {
|
|
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
|
|
updateFilterBarVisibility()
|
|
}
|
|
}
|
|
|
|
PrefKeys.READING_ORDER -> {
|
|
readingOrder = ReadingOrder.from(
|
|
preferences.getString(PrefKeys.READING_ORDER, null)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateFilterBarVisibility() {
|
|
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
|
|
if (showNotificationsFilterBar) {
|
|
binding.appBarOptions.setExpanded(true, false)
|
|
binding.appBarOptions.show()
|
|
// Set content behaviour to hide filter on scroll
|
|
params.behavior = AppBarLayout.ScrollingViewBehavior()
|
|
} else {
|
|
binding.appBarOptions.setExpanded(false, false)
|
|
binding.appBarOptions.hide()
|
|
// Clear behaviour to hide app bar
|
|
params.behavior = null
|
|
}
|
|
}
|
|
|
|
private fun updateReadingPositionForOldestFirst() {
|
|
var position = loadMorePosition ?: return
|
|
val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return
|
|
|
|
var notification: NotificationViewData?
|
|
while (adapter.peek(position).let {
|
|
notification = it
|
|
it != null
|
|
}
|
|
) {
|
|
if (notification?.id == notificationIdBelowLoadMore) {
|
|
val lastVisiblePosition =
|
|
(binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
|
|
if (position > lastVisiblePosition) {
|
|
binding.recyclerView.scrollToPosition(position)
|
|
}
|
|
break
|
|
}
|
|
position++
|
|
}
|
|
loadMorePosition = null
|
|
}
|
|
|
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
|
menuInflater.inflate(R.menu.fragment_notifications, menu)
|
|
}
|
|
|
|
override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) {
|
|
R.id.action_refresh -> {
|
|
binding.swipeRefreshLayout.isRefreshing = true
|
|
onRefresh()
|
|
true
|
|
}
|
|
R.id.action_edit_notification_filter -> {
|
|
showFilterMenu()
|
|
true
|
|
}
|
|
R.id.action_clear_notifications -> {
|
|
confirmClearNotifications()
|
|
true
|
|
}
|
|
else -> false
|
|
}
|
|
|
|
companion object {
|
|
fun newInstance() = NotificationsFragment()
|
|
}
|
|
}
|