diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index aac7218d0..c5742d73a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -149,6 +149,9 @@
+
+
+
. */
+
+package com.keylesspalace.tusky.components.notifications
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemFilteredNotificationsInfoBinding
+import com.keylesspalace.tusky.usecase.NotificationPolicyState
+import com.keylesspalace.tusky.util.BindingHolder
+import java.text.NumberFormat
+
+class NotificationPolicySummaryAdapter(
+ private val onOpenDetails: () -> Unit
+) : RecyclerView.Adapter>() {
+
+ private var state: NotificationPolicyState = NotificationPolicyState.Loading
+
+ fun updateState(newState: NotificationPolicyState) {
+ val oldShowInfo = state.shouldShowInfo()
+ val newShowInfo = newState.shouldShowInfo()
+ state = newState
+ if (oldShowInfo && !newShowInfo) {
+ notifyItemRemoved(0)
+ } else if (!oldShowInfo && newShowInfo) {
+ notifyItemInserted(0)
+ } else if (oldShowInfo && newShowInfo) {
+ notifyItemChanged(0)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder {
+ val binding = ItemFilteredNotificationsInfoBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ binding.root.setOnClickListener {
+ onOpenDetails()
+ }
+ return BindingHolder(binding)
+ }
+
+ override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0
+
+ override fun onBindViewHolder(holder: BindingHolder, position: Int) {
+ val policySummary = (state as? NotificationPolicyState.Loaded)?.policy?.summary
+ if (policySummary != null) {
+ val binding = holder.binding
+ val context = holder.binding.root.context
+ binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policySummary.pendingRequestsCount)
+ binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policySummary.pendingNotificationsCount)
+ }
+ }
+
+ private fun NotificationPolicyState.shouldShowInfo(): Boolean {
+ return this is NotificationPolicyState.Loaded && this.policy.summary.pendingNotificationsCount > 0
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
index 0ad4deffc..66404e7b9 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
@@ -24,6 +24,7 @@ import com.keylesspalace.tusky.db.entity.NotificationReportEntity
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Report
+import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
@@ -52,6 +53,22 @@ fun Notification.toEntity(
loading = false
)
+fun Notification.toViewData(
+ isShowingContent: Boolean,
+ isExpanded: Boolean,
+ isCollapsed: Boolean,
+): NotificationViewData.Concrete = NotificationViewData.Concrete(
+ id = id,
+ type = type,
+ account = account,
+ statusViewData = status?.toViewData(
+ isShowingContent = isShowingContent,
+ isExpanded = isExpanded,
+ isCollapsed = isCollapsed
+ ),
+ report = report
+)
+
fun Report.toEntity(
tuskyAccountId: Long
) = NotificationReportEntity(
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
index 089f66b62..168b9cf56 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
@@ -34,6 +34,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState
+import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -44,10 +45,12 @@ import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
+import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
+import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
@@ -65,6 +68,7 @@ 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.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
@@ -97,7 +101,8 @@ class NotificationsFragment :
private val viewModel: NotificationsViewModel by viewModels()
- private var adapter: NotificationsPagingAdapter? = null
+ private var notificationsAdapter: NotificationsPagingAdapter? = null
+ private var notificationsPolicyAdapter: NotificationPolicySummaryAdapter? = null
private var showNotificationsFilterBar: Boolean = true
private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST
@@ -147,7 +152,7 @@ class NotificationsFragment :
accountActionListener = this,
statusDisplayOptions = statusDisplayOptions
)
- this.adapter = adapter
+ this.notificationsAdapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(
@@ -169,7 +174,12 @@ class NotificationsFragment :
)
)
- binding.recyclerView.adapter = adapter
+ val notificationsPolicyAdapter = NotificationPolicySummaryAdapter {
+ (activity as BaseActivity).startActivityWithSlideInAnimation(NotificationRequestsActivity.newIntent(requireContext()))
+ }
+ this.notificationsPolicyAdapter = notificationsPolicyAdapter
+
+ binding.recyclerView.adapter = ConcatAdapter(notificationsPolicyAdapter, notificationsAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
@@ -179,6 +189,12 @@ class NotificationsFragment :
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
+ notificationsPolicyAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
+ override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
+ binding.recyclerView.scrollToPosition(0)
+ }
+ })
+
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
@@ -257,11 +273,18 @@ class NotificationsFragment :
}
}
}
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.notificationPolicy.collect {
+ notificationsPolicyAdapter.updateState(it)
+ }
+ }
}
override fun onDestroyView() {
- // Clear the adapter to prevent leaking the View
- adapter = null
+ // Clear the adapters to prevent leaking the View
+ notificationsAdapter = null
+ notificationsPolicyAdapter = null
super.onDestroyView()
}
@@ -273,7 +296,8 @@ class NotificationsFragment :
}
override fun onRefresh() {
- adapter?.refresh()
+ notificationsAdapter?.refresh()
+ viewModel.loadNotificationPolicy()
}
override fun onViewAccount(id: String) {
@@ -289,11 +313,11 @@ class NotificationsFragment :
}
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
- val notification = adapter?.peek(position) ?: return
+ val notification = notificationsAdapter?.peek(position) ?: return
viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
}
- override fun onViewReport(reportId: String?) {
+ override fun onViewReport(reportId: String) {
requireContext().openLink(
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
)
@@ -304,17 +328,17 @@ class NotificationsFragment :
}
override fun onReply(position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status)
}
override fun removeItem(position: Int) {
- val notification = adapter?.peek(position) ?: return
+ val notification = notificationsAdapter?.peek(position) ?: return
viewModel.remove(notification.id)
}
override fun onReblog(reblog: Boolean, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status)
}
@@ -328,7 +352,7 @@ class NotificationsFragment :
}
private fun onTranslate(position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(status)
.onFailure {
@@ -342,32 +366,32 @@ class NotificationsFragment :
}
override fun onUntranslate(position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.bookmark(bookmark, status)
}
override fun onVoteInPoll(position: Int, choices: List) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.voteInPoll(choices, status)
}
override fun clearWarningAction(position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.clearWarning(status)
}
override fun onMore(view: View, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.more(
status.status,
view,
@@ -377,32 +401,32 @@ class NotificationsFragment :
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
}
override fun onViewThread(position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull()?.status ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull()?.status ?: return
super.viewThread(status.id, status.url)
}
override fun onOpenReblog(position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.openReblog(status.status)
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeExpanded(expanded, status)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentShowing(isShowing, status)
}
override fun onLoadMore(position: Int) {
- val adapter = this.adapter
+ val adapter = this.notificationsAdapter
val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return
loadMorePosition = position
statusIdBelowLoadMore =
@@ -411,7 +435,7 @@ class NotificationsFragment :
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
- val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentCollapsed(isCollapsed, status)
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt
index 26af3b8d8..230a33403 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt
@@ -41,7 +41,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
interface NotificationActionListener {
- fun onViewReport(reportId: String?)
+ fun onViewReport(reportId: String)
}
interface NotificationsViewHolder {
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
index 838aecee0..38d5b9854 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
@@ -45,6 +45,8 @@ import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.usecase.NotificationPolicyState
+import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize
@@ -74,6 +76,7 @@ class NotificationsViewModel @Inject constructor(
private val preferences: SharedPreferences,
private val filterModel: FilterModel,
private val db: AppDatabase,
+ private val notificationPolicyUsecase: NotificationPolicyUsecase
) : ViewModel() {
private val refreshTrigger = MutableStateFlow(0L)
@@ -116,6 +119,8 @@ class NotificationsViewModel @Inject constructor(
}
.flowOn(Dispatchers.Default)
+ val notificationPolicy: StateFlow = notificationPolicyUsecase.state
+
init {
viewModelScope.launch {
eventHub.events.collect { event ->
@@ -134,6 +139,13 @@ class NotificationsViewModel @Inject constructor(
refreshTrigger.value++
}
}
+ loadNotificationPolicy()
+ }
+
+ fun loadNotificationPolicy() {
+ viewModelScope.launch {
+ notificationPolicyUsecase.getNotificationPolicy()
+ }
}
fun updateNotificationFilters(newFilters: Set) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt
new file mode 100644
index 000000000..c8e6fcae3
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt
@@ -0,0 +1,204 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.activity.viewModels
+import androidx.core.view.MenuProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.paging.LoadState
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.SimpleItemAnimator
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
+import com.google.android.material.snackbar.Snackbar
+import com.keylesspalace.tusky.BaseActivity
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsActivity
+import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity
+import com.keylesspalace.tusky.databinding.ActivityNotificationRequestsBinding
+import com.keylesspalace.tusky.entity.Emoji
+import com.keylesspalace.tusky.entity.NotificationRequest
+import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.util.getErrorString
+import com.keylesspalace.tusky.util.hide
+import com.keylesspalace.tusky.util.show
+import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
+import com.keylesspalace.tusky.util.viewBinding
+import com.keylesspalace.tusky.util.visible
+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 dagger.hilt.android.AndroidEntryPoint
+import kotlin.String
+import kotlin.getValue
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class NotificationRequestsActivity : BaseActivity(), MenuProvider {
+
+ private val viewModel: NotificationRequestsViewModel by viewModels()
+
+ private val binding by viewBinding(ActivityNotificationRequestsBinding::inflate)
+
+ private val notificationRequestDetails = registerForActivityResult(NotificationRequestDetailsResultContract()) { id ->
+ if (id != null) {
+ viewModel.removeNotificationRequest(id)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(binding.root)
+
+ addMenuProvider(this)
+
+ setSupportActionBar(binding.includedToolbar.toolbar)
+ supportActionBar?.run {
+ setTitle(R.string.filtered_notifications_title)
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowHomeEnabled(true)
+ }
+
+ setupAdapter().let { adapter ->
+ setupRecyclerView(adapter)
+
+ lifecycleScope.launch {
+ viewModel.pager.collectLatest { pagingData ->
+ adapter.submitData(pagingData)
+ }
+ }
+ }
+
+ lifecycleScope.launch {
+ viewModel.error.collect { error ->
+ Snackbar.make(
+ binding.root,
+ error.getErrorString(this@NotificationRequestsActivity),
+ LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+
+ private fun setupRecyclerView(adapter: NotificationRequestsAdapter) {
+ binding.notificationRequestsView.adapter = adapter
+ binding.notificationRequestsView.setHasFixedSize(true)
+ binding.notificationRequestsView.layoutManager = LinearLayoutManager(this)
+ binding.notificationRequestsView.addItemDecoration(
+ DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
+ )
+ (binding.notificationRequestsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
+ }
+
+ private fun setupAdapter(): NotificationRequestsAdapter {
+ return NotificationRequestsAdapter(
+ onAcceptRequest = viewModel::acceptNotificationRequest,
+ onDismissRequest = viewModel::dismissNotificationRequest,
+ onOpenDetails = ::onOpenRequestDetails,
+ animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
+ animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+ ).apply {
+ addLoadStateListener { loadState ->
+ binding.notificationRequestsProgressBar.visible(
+ loadState.refresh == LoadState.Loading && itemCount == 0
+ )
+
+ if (loadState.refresh is LoadState.Error) {
+ binding.notificationRequestsView.hide()
+ binding.notificationRequestsMessageView.show()
+ val errorState = loadState.refresh as LoadState.Error
+ binding.notificationRequestsMessageView.setup(errorState.error) { retry() }
+ Log.w(TAG, "error loading notification requests", errorState.error)
+ } else {
+ binding.notificationRequestsView.show()
+ binding.notificationRequestsMessageView.hide()
+ }
+ }
+ }
+ }
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.activity_notification_requests, menu)
+ menu.findItem(R.id.open_settings)?.apply {
+ icon = IconicsDrawable(this@NotificationRequestsActivity, GoogleMaterial.Icon.gmd_settings).apply {
+ sizeDp = 20
+ colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
+ }
+ }
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.open_settings -> {
+ val intent = NotificationPoliciesActivity.newIntent(this)
+ startActivityWithSlideInAnimation(intent)
+ true
+ }
+ else -> false
+ }
+ }
+
+ private fun onOpenRequestDetails(reqeuest: NotificationRequest) {
+ notificationRequestDetails.launch(
+ NotificationRequestDetailsResultContractInput(
+ notificationRequestId = reqeuest.id,
+ accountId = reqeuest.account.id,
+ accountName = reqeuest.account.name,
+ accountEmojis = reqeuest.account.emojis
+ )
+ )
+ }
+
+ class NotificationRequestDetailsResultContractInput(
+ val notificationRequestId: String,
+ val accountId: String,
+ val accountName: String,
+ val accountEmojis: List
+ )
+
+ class NotificationRequestDetailsResultContract : ActivityResultContract() {
+ override fun createIntent(context: Context, input: NotificationRequestDetailsResultContractInput): Intent {
+ return NotificationRequestDetailsActivity.newIntent(
+ notificationRequestId = input.notificationRequestId,
+ accountId = input.accountId,
+ accountName = input.accountName,
+ accountEmojis = input.accountEmojis,
+ context = context
+ )
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): String? {
+ return intent?.getStringExtra(NotificationRequestDetailsActivity.EXTRA_NOTIFICATION_REQUEST_ID)
+ }
+ }
+
+ companion object {
+ private const val TAG = "NotificationRequestsActivity"
+ fun newIntent(context: Context) = Intent(context, NotificationRequestsActivity::class.java)
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt
new file mode 100644
index 000000000..6d0594fb6
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt
@@ -0,0 +1,92 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.annotation.OptIn
+import androidx.paging.PagingDataAdapter
+import androidx.recyclerview.widget.DiffUtil
+import com.google.android.material.badge.ExperimentalBadgeUtils
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemNotificationRequestBinding
+import com.keylesspalace.tusky.entity.NotificationRequest
+import com.keylesspalace.tusky.util.BindingHolder
+import com.keylesspalace.tusky.util.emojify
+import com.keylesspalace.tusky.util.loadAvatar
+
+class NotificationRequestsAdapter(
+ private val onAcceptRequest: (notificationRequestId: String) -> Unit,
+ private val onDismissRequest: (notificationRequestId: String) -> Unit,
+ private val onOpenDetails: (notificationRequest: NotificationRequest) -> Unit,
+ private val animateAvatar: Boolean,
+ private val animateEmojis: Boolean,
+) : PagingDataAdapter>(NOTIFICATION_REQUEST_COMPARATOR) {
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): BindingHolder {
+ val binding = ItemNotificationRequestBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return BindingHolder(binding)
+ }
+
+ @OptIn(ExperimentalBadgeUtils::class)
+ override fun onBindViewHolder(holder: BindingHolder, position: Int) {
+ getItem(position)?.let { notificationRequest ->
+ val binding = holder.binding
+ val context = binding.root.context
+ val account = notificationRequest.account
+
+ val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
+ loadAvatar(account.avatar, binding.notificationRequestAvatar, avatarRadius, animateAvatar)
+
+ binding.notificationRequestBadge.text = notificationRequest.notificationsCount
+
+ val emojifiedName = account.name.emojify(
+ account.emojis,
+ binding.notificationRequestDisplayName,
+ animateEmojis
+ )
+ binding.notificationRequestDisplayName.text = emojifiedName
+ val formattedUsername = context.getString(R.string.post_username_format, account.username)
+ binding.notificationRequestUsername.text = formattedUsername
+
+ binding.notificationRequestAccept.setOnClickListener {
+ onAcceptRequest(notificationRequest.id)
+ }
+ binding.notificationRequestDismiss.setOnClickListener {
+ onDismissRequest(notificationRequest.id)
+ }
+ binding.root.setOnClickListener {
+ onOpenDetails(notificationRequest)
+ }
+ }
+ }
+
+ companion object {
+ val NOTIFICATION_REQUEST_COMPARATOR = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean =
+ oldItem.id == newItem.id
+ override fun areContentsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean =
+ oldItem == newItem
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt
new file mode 100644
index 000000000..b0f74bfa4
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt
@@ -0,0 +1,35 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.keylesspalace.tusky.entity.NotificationRequest
+
+class NotificationRequestsPagingSource(
+ private val requests: List,
+ private val nextKey: String?
+) : PagingSource() {
+ override fun getRefreshKey(state: PagingState): String? = null
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return if (params is LoadParams.Refresh) {
+ LoadResult.Page(requests.toList(), null, nextKey)
+ } else {
+ LoadResult.Page(emptyList(), null, null)
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt
new file mode 100644
index 000000000..e1dd8834c
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt
@@ -0,0 +1,73 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests
+
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.LoadType
+import androidx.paging.PagingState
+import androidx.paging.RemoteMediator
+import com.keylesspalace.tusky.entity.NotificationRequest
+import com.keylesspalace.tusky.network.MastodonApi
+import com.keylesspalace.tusky.util.HttpHeaderLink
+import retrofit2.HttpException
+import retrofit2.Response
+
+@OptIn(ExperimentalPagingApi::class)
+class NotificationRequestsRemoteMediator(
+ private val api: MastodonApi,
+ private val viewModel: NotificationRequestsViewModel
+) : RemoteMediator() {
+
+ override suspend fun load(
+ loadType: LoadType,
+ state: PagingState
+ ): MediatorResult {
+ return try {
+ val response = request(loadType)
+ ?: return MediatorResult.Success(endOfPaginationReached = true)
+
+ return applyResponse(response)
+ } catch (e: Exception) {
+ MediatorResult.Error(e)
+ }
+ }
+
+ private suspend fun request(loadType: LoadType): Response>? {
+ return when (loadType) {
+ LoadType.PREPEND -> null
+ LoadType.APPEND -> api.getNotificationRequests(maxId = viewModel.nextKey)
+ LoadType.REFRESH -> {
+ viewModel.nextKey = null
+ viewModel.requestData.clear()
+ api.getNotificationRequests()
+ }
+ }
+ }
+
+ private fun applyResponse(response: Response>): MediatorResult {
+ val notificationRequests = response.body()
+ if (!response.isSuccessful || notificationRequests == null) {
+ return MediatorResult.Error(HttpException(response))
+ }
+
+ val links = HttpHeaderLink.parse(response.headers()["Link"])
+ viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
+ viewModel.requestData.addAll(notificationRequests)
+ viewModel.currentSource?.invalidate()
+
+ return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null)
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt
new file mode 100644
index 000000000..b929b5ca7
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt
@@ -0,0 +1,123 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.cachedIn
+import at.connyduck.calladapter.networkresult.fold
+import com.keylesspalace.tusky.appstore.BlockEvent
+import com.keylesspalace.tusky.appstore.EventHub
+import com.keylesspalace.tusky.appstore.MuteEvent
+import com.keylesspalace.tusky.entity.NotificationRequest
+import com.keylesspalace.tusky.network.MastodonApi
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+
+@HiltViewModel
+class NotificationRequestsViewModel @Inject constructor(
+ private val api: MastodonApi,
+ private val eventHub: EventHub
+) : ViewModel() {
+
+ var currentSource: NotificationRequestsPagingSource? = null
+
+ val requestData: MutableList = mutableListOf()
+
+ var nextKey: String? = null
+
+ @OptIn(ExperimentalPagingApi::class)
+ val pager = Pager(
+ config = PagingConfig(
+ pageSize = 20,
+ initialLoadSize = 20
+ ),
+ remoteMediator = NotificationRequestsRemoteMediator(api, this),
+ pagingSourceFactory = {
+ NotificationRequestsPagingSource(
+ requests = requestData,
+ nextKey = nextKey
+ ).also { source ->
+ currentSource = source
+ }
+ }
+ ).flow
+ .cachedIn(viewModelScope)
+
+ private val _error = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val error: SharedFlow = _error.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ eventHub.events
+ .collect { event ->
+ when (event) {
+ is BlockEvent -> removeAllByAccount(event.accountId)
+ is MuteEvent -> removeAllByAccount(event.accountId)
+ }
+ }
+ }
+ }
+
+ fun acceptNotificationRequest(id: String) {
+ viewModelScope.launch {
+ api.acceptNotificationRequest(id).fold({
+ removeNotificationRequest(id)
+ }, { error ->
+ Log.w(TAG, "failed to dismiss notifications request", error)
+ _error.emit(error)
+ })
+ }
+ }
+
+ fun dismissNotificationRequest(id: String) {
+ viewModelScope.launch {
+ api.dismissNotificationRequest(id).fold({
+ removeNotificationRequest(id)
+ }, { error ->
+ Log.w(TAG, "failed to dismiss notifications request", error)
+ _error.emit(error)
+ })
+ }
+ }
+
+ fun removeNotificationRequest(id: String) {
+ requestData.removeAll { request -> request.id == id }
+ currentSource?.invalidate()
+ }
+
+ private fun removeAllByAccount(accountId: String) {
+ requestData.removeAll { request -> request.account.id == accountId }
+ currentSource?.invalidate()
+ }
+
+ companion object {
+ private const val TAG = "NotificationRequestsViewModel"
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt
new file mode 100644
index 000000000..acc2c3807
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt
@@ -0,0 +1,105 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests.details
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.lifecycle.lifecycleScope
+import com.keylesspalace.tusky.BottomSheetActivity
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ActivityNotificationRequestDetailsBinding
+import com.keylesspalace.tusky.entity.Emoji
+import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.util.emojify
+import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat
+import com.keylesspalace.tusky.util.viewBinding
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.lifecycle.withCreationCallback
+import kotlin.getValue
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class NotificationRequestDetailsActivity : BottomSheetActivity() {
+
+ private val viewModel: NotificationRequestDetailsViewModel by viewModels(
+ extrasProducer = {
+ defaultViewModelCreationExtras.withCreationCallback { factory ->
+ factory.create(
+ notificationRequestId = intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!,
+ accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID)!!
+ )
+ }
+ }
+ )
+
+ private val binding by viewBinding(ActivityNotificationRequestDetailsBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(binding.root)
+
+ setSupportActionBar(binding.includedToolbar.toolbar)
+
+ val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+
+ val emojis: List = intent.getParcelableArrayListExtraCompat(EXTRA_ACCOUNT_EMOJIS)!!
+
+ val title = getString(R.string.notifications_from, intent.getStringExtra(EXTRA_ACCOUNT_NAME))
+ .emojify(emojis, binding.includedToolbar.toolbar, animateEmojis)
+
+ supportActionBar?.run {
+ setTitle(title)
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowHomeEnabled(true)
+ }
+
+ lifecycleScope.launch {
+ viewModel.finish.collect { finishMode ->
+ setResult(RESULT_OK, Intent().apply { putExtra(EXTRA_NOTIFICATION_REQUEST_ID, intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!) })
+ finish()
+ }
+ }
+
+ binding.acceptButton.setOnClickListener {
+ viewModel.acceptNotificationRequest()
+ }
+ binding.dismissButtin.setOnClickListener {
+ viewModel.dismissNotificationRequest()
+ }
+ }
+
+ companion object {
+ const val EXTRA_NOTIFICATION_REQUEST_ID = "notificationRequestId"
+ private const val EXTRA_ACCOUNT_ID = "accountId"
+ private const val EXTRA_ACCOUNT_NAME = "accountName"
+ private const val EXTRA_ACCOUNT_EMOJIS = "accountEmojis"
+ fun newIntent(
+ notificationRequestId: String,
+ accountId: String,
+ accountName: String,
+ accountEmojis: List,
+ context: Context
+ ) = Intent(context, NotificationRequestDetailsActivity::class.java).apply {
+ putExtra(EXTRA_NOTIFICATION_REQUEST_ID, notificationRequestId)
+ putExtra(EXTRA_ACCOUNT_ID, accountId)
+ putExtra(EXTRA_ACCOUNT_NAME, accountName)
+ putExtra(EXTRA_ACCOUNT_EMOJIS, ArrayList(accountEmojis))
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt
new file mode 100644
index 000000000..5ad208388
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt
@@ -0,0 +1,296 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests.details
+
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.paging.LoadState
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.SimpleItemAnimator
+import at.connyduck.calladapter.networkresult.onFailure
+import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
+import com.google.android.material.snackbar.Snackbar
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.components.notifications.NotificationActionListener
+import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
+import com.keylesspalace.tusky.databinding.FragmentNotificationRequestDetailsBinding
+import com.keylesspalace.tusky.fragment.SFragment
+import com.keylesspalace.tusky.interfaces.AccountActionListener
+import com.keylesspalace.tusky.interfaces.StatusActionListener
+import com.keylesspalace.tusky.settings.PrefKeys
+import com.keylesspalace.tusky.util.CardViewMode
+import com.keylesspalace.tusky.util.StatusDisplayOptions
+import com.keylesspalace.tusky.util.getErrorString
+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.util.visible
+import com.keylesspalace.tusky.viewdata.AttachmentViewData
+import com.keylesspalace.tusky.viewdata.TranslationViewData
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import kotlin.getValue
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notification_request_details), StatusActionListener, NotificationActionListener, AccountActionListener {
+
+ @Inject
+ lateinit var preferences: SharedPreferences
+
+ private val viewModel: NotificationRequestDetailsViewModel by activityViewModels()
+
+ private val binding by viewBinding(FragmentNotificationRequestDetailsBinding::bind)
+
+ private var adapter: NotificationsPagingAdapter? = null
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupAdapter().let { adapter ->
+ this.adapter = adapter
+ setupRecyclerView(adapter)
+
+ lifecycleScope.launch {
+ viewModel.pager.collectLatest { pagingData ->
+ adapter.submitData(pagingData)
+ }
+ }
+ }
+
+ lifecycleScope.launch {
+ viewModel.error.collect { error ->
+ Snackbar.make(
+ binding.root,
+ error.getErrorString(requireContext()),
+ LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+
+ private fun setupRecyclerView(adapter: NotificationsPagingAdapter) {
+ binding.recyclerView.adapter = adapter
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
+ binding.recyclerView.addItemDecoration(
+ DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)
+ )
+ (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
+ }
+
+ private fun setupAdapter(): NotificationsPagingAdapter {
+ 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
+ )
+
+ return NotificationsPagingAdapter(
+ accountId = accountManager.activeAccount!!.accountId,
+ statusDisplayOptions = statusDisplayOptions,
+ statusListener = this,
+ notificationActionListener = this,
+ accountActionListener = this
+ ).apply {
+ addLoadStateListener { loadState ->
+ binding.progressBar.visible(
+ loadState.refresh == LoadState.Loading && itemCount == 0
+ )
+
+ if (loadState.refresh is LoadState.Error) {
+ binding.recyclerView.hide()
+ binding.statusView.show()
+ val errorState = loadState.refresh as LoadState.Error
+ binding.statusView.setup(errorState.error) { retry() }
+ Log.w(TAG, "error loading notifications for user ${viewModel.accountId}", errorState.error)
+ } else {
+ binding.recyclerView.show()
+ binding.statusView.hide()
+ }
+ }
+ }
+ }
+
+ 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)
+ }
+
+ override fun onReblog(reblog: Boolean, position: Int) {
+ val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ viewModel.reblog(reblog, status)
+ }
+
+ override val onMoreTranslate: ((Boolean, Int) -> Unit)?
+ get() = { translate: Boolean, position: Int ->
+ if (translate) {
+ onTranslate(position)
+ } else {
+ onUntranslate(position)
+ }
+ }
+
+ 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 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() ?: 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) {
+ // not applicable here
+ }
+
+ override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
+ val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ viewModel.changeContentCollapsed(isCollapsed, status)
+ }
+
+ override fun onVoteInPoll(position: Int, choices: List) {
+ val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ viewModel.voteInPoll(choices, status)
+ }
+
+ override fun clearWarningAction(position: Int) {
+ // not applicable here
+ }
+
+ private fun onTranslate(position: Int) {
+ val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.translate(status)
+ .onFailure {
+ Snackbar.make(
+ requireView(),
+ getString(R.string.ui_error_translate, it.message),
+ LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+
+ override fun onUntranslate(position: Int) {
+ val status = adapter?.peek(position)?.asStatusOrNull() ?: return
+ viewModel.untranslate(status)
+ }
+
+ override fun onViewTag(tag: String) {
+ super.viewTag(tag)
+ }
+
+ override fun onViewAccount(id: String) {
+ super.viewAccount(id)
+ }
+
+ override fun onViewReport(reportId: String) {
+ requireContext().openLink(
+ "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
+ )
+ }
+
+ 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
+ viewModel.respondToFollowRequest(accept, accountId = id, notification = notification)
+ }
+
+ override fun onDestroyView() {
+ adapter = null
+ super.onDestroyView()
+ }
+
+ companion object {
+ private const val TAG = "NotificationRequestsDetailsFragment"
+ private const val EXTRA_ACCOUNT_ID = "accountId"
+ fun newIntent(accountId: String, context: Context) = Intent(context, NotificationRequestDetailsActivity::class.java).apply {
+ putExtra(EXTRA_ACCOUNT_ID, accountId)
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt
new file mode 100644
index 000000000..a7cb903a7
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt
@@ -0,0 +1,35 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests.details
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+
+class NotificationRequestDetailsPagingSource(
+ private val notifications: List,
+ private val nextKey: String?
+) : PagingSource() {
+ override fun getRefreshKey(state: PagingState): String? = null
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return if (params is LoadParams.Refresh) {
+ LoadResult.Page(notifications.toList(), null, nextKey)
+ } else {
+ LoadResult.Page(emptyList(), null, null)
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt
new file mode 100644
index 000000000..c1f8ca984
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt
@@ -0,0 +1,84 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests.details
+
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.LoadType
+import androidx.paging.PagingState
+import androidx.paging.RemoteMediator
+import com.keylesspalace.tusky.components.notifications.toViewData
+import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.util.HttpHeaderLink
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+import retrofit2.HttpException
+import retrofit2.Response
+
+@OptIn(ExperimentalPagingApi::class)
+class NotificationRequestDetailsRemoteMediator(
+ private val viewModel: NotificationRequestDetailsViewModel
+) : RemoteMediator() {
+
+ override suspend fun load(
+ loadType: LoadType,
+ state: PagingState
+ ): MediatorResult {
+ return try {
+ val response = request(loadType)
+ ?: return MediatorResult.Success(endOfPaginationReached = true)
+
+ return applyResponse(response)
+ } catch (e: Exception) {
+ MediatorResult.Error(e)
+ }
+ }
+
+ private suspend fun request(loadType: LoadType): Response>? {
+ return when (loadType) {
+ LoadType.PREPEND -> null
+ LoadType.APPEND -> viewModel.api.notifications(maxId = viewModel.nextKey, accountId = viewModel.accountId)
+ LoadType.REFRESH -> {
+ viewModel.nextKey = null
+ viewModel.notificationData.clear()
+ viewModel.api.notifications(accountId = viewModel.accountId)
+ }
+ }
+ }
+
+ private fun applyResponse(response: Response>): MediatorResult {
+ val notifications = response.body()
+ if (!response.isSuccessful || notifications == null) {
+ return MediatorResult.Error(HttpException(response))
+ }
+
+ val links = HttpHeaderLink.parse(response.headers()["Link"])
+ viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
+
+ val alwaysShowSensitiveMedia = viewModel.accountManager.activeAccount?.alwaysShowSensitiveMedia == true
+ val alwaysOpenSpoiler = viewModel.accountManager.activeAccount?.alwaysOpenSpoiler == false
+ val notificationData = notifications.map { notification ->
+ notification.toViewData(
+ isShowingContent = alwaysShowSensitiveMedia,
+ isExpanded = alwaysOpenSpoiler,
+ true
+ )
+ }
+
+ viewModel.notificationData.addAll(notificationData)
+ viewModel.currentSource?.invalidate()
+
+ return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null)
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt
new file mode 100644
index 000000000..521bd0a5a
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt
@@ -0,0 +1,268 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.notifications.requests.details
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.cachedIn
+import at.connyduck.calladapter.networkresult.NetworkResult
+import at.connyduck.calladapter.networkresult.fold
+import at.connyduck.calladapter.networkresult.map
+import at.connyduck.calladapter.networkresult.onFailure
+import com.keylesspalace.tusky.appstore.BlockEvent
+import com.keylesspalace.tusky.appstore.EventHub
+import com.keylesspalace.tusky.appstore.MuteEvent
+import com.keylesspalace.tusky.appstore.StatusChangedEvent
+import com.keylesspalace.tusky.components.timeline.util.ifExpected
+import com.keylesspalace.tusky.db.AccountManager
+import com.keylesspalace.tusky.entity.Status
+import com.keylesspalace.tusky.network.MastodonApi
+import com.keylesspalace.tusky.usecase.TimelineCases
+import com.keylesspalace.tusky.viewdata.NotificationViewData
+import com.keylesspalace.tusky.viewdata.StatusViewData
+import com.keylesspalace.tusky.viewdata.TranslationViewData
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+
+@HiltViewModel(assistedFactory = NotificationRequestDetailsViewModel.Factory::class)
+class NotificationRequestDetailsViewModel @AssistedInject constructor(
+ val api: MastodonApi,
+ val accountManager: AccountManager,
+ val timelineCases: TimelineCases,
+ val eventHub: EventHub,
+ @Assisted("notificationRequestId") val notificationRequestId: String,
+ @Assisted("accountId") val accountId: String
+) : ViewModel() {
+
+ var currentSource: NotificationRequestDetailsPagingSource? = null
+
+ val notificationData: MutableList = mutableListOf()
+
+ var nextKey: String? = null
+
+ @OptIn(ExperimentalPagingApi::class)
+ val pager = Pager(
+ config = PagingConfig(
+ pageSize = 20,
+ initialLoadSize = 20
+ ),
+ remoteMediator = NotificationRequestDetailsRemoteMediator(this),
+ pagingSourceFactory = {
+ NotificationRequestDetailsPagingSource(
+ notifications = notificationData,
+ nextKey = nextKey
+ ).also { source ->
+ currentSource = source
+ }
+ }
+ ).flow
+ .cachedIn(viewModelScope)
+
+ private val _error = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val error: SharedFlow = _error.asSharedFlow()
+
+ private val _finish = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val finish: SharedFlow = _finish.asSharedFlow()
+
+ init {
+ viewModelScope.launch {
+ eventHub.events
+ .collect { event ->
+ when (event) {
+ is StatusChangedEvent -> updateStatus(event.status)
+ is BlockEvent -> removeIfAccount(event.accountId)
+ is MuteEvent -> removeIfAccount(event.accountId)
+ }
+ }
+ }
+ }
+
+ fun acceptNotificationRequest() {
+ viewModelScope.launch {
+ api.acceptNotificationRequest(notificationRequestId).fold(
+ {
+ _finish.emit(Unit)
+ },
+ { error ->
+ Log.w(TAG, "failed to dismiss notifications request", error)
+ _error.emit(error)
+ }
+ )
+ }
+ }
+
+ fun dismissNotificationRequest() {
+ viewModelScope.launch {
+ api.dismissNotificationRequest(notificationRequestId).fold({
+ _finish.emit(Unit)
+ }, { error ->
+ Log.w(TAG, "failed to dismiss notifications request", error)
+ _error.emit(error)
+ })
+ }
+ }
+
+ private fun updateStatus(status: Status) {
+ val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == status.id }
+ if (position == -1) {
+ return
+ }
+ val viewData = notificationData[position].statusViewData?.copy(status = status)
+ notificationData[position] = notificationData[position].copy(statusViewData = viewData)
+ currentSource?.invalidate()
+ }
+
+ private fun removeIfAccount(accountId: String) {
+ // if the account we are displaying notifications from got blocked or muted, we can exit
+ if (accountId == this.accountId) {
+ viewModelScope.launch {
+ _finish.emit(Unit)
+ }
+ }
+ }
+
+ fun remove(notification: NotificationViewData) {
+ notificationData.remove(notification)
+ currentSource?.invalidate()
+ }
+
+ fun reblog(reblog: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch {
+ timelineCases.reblog(status.actionableId, reblog).onFailure { t ->
+ ifExpected(t) {
+ Log.w(TAG, "Failed to reblog status " + status.actionableId, t)
+ }
+ }
+ }
+
+ fun favorite(favorite: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch {
+ timelineCases.favourite(status.actionableId, favorite).onFailure { t ->
+ ifExpected(t) {
+ Log.w(TAG, "Failed to favourite status " + status.actionableId, t)
+ }
+ }
+ }
+
+ fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch {
+ timelineCases.bookmark(status.actionableId, bookmark).onFailure { t ->
+ ifExpected(t) {
+ Log.w(TAG, "Failed to favourite status " + status.actionableId, t)
+ }
+ }
+ }
+
+ fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
+ updateStatusViewData(status.id) { it.copy(isExpanded = expanded) }
+ }
+
+ fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
+ updateStatusViewData(status.id) { it.copy(isShowingContent = isShowing) }
+ }
+
+ fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
+ updateStatusViewData(status.id) { it.copy(isCollapsed = isCollapsed) }
+ }
+
+ fun voteInPoll(choices: List, status: StatusViewData.Concrete) = viewModelScope.launch {
+ val poll = status.status.actionableStatus.poll ?: run {
+ Log.w(TAG, "No poll on status ${status.id}")
+ return@launch
+ }
+ timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t ->
+ ifExpected(t) {
+ Log.w(TAG, "Failed to vote in poll: " + status.actionableId, t)
+ }
+ }
+ }
+
+ suspend fun translate(status: StatusViewData.Concrete): NetworkResult {
+ updateStatusViewData(status.id) { viewData ->
+ viewData.copy(translation = TranslationViewData.Loading)
+ }
+ return timelineCases.translate(status.actionableId)
+ .map { translation ->
+ updateStatusViewData(status.id) { viewData ->
+ viewData.copy(translation = TranslationViewData.Loaded(translation))
+ }
+ }
+ .onFailure {
+ updateStatusViewData(status.id) { viewData ->
+ viewData.copy(translation = null)
+ }
+ }
+ }
+
+ fun untranslate(status: StatusViewData.Concrete) {
+ updateStatusViewData(status.id) { it.copy(translation = null) }
+ }
+
+ fun respondToFollowRequest(accept: Boolean, accountId: String, notification: NotificationViewData) {
+ viewModelScope.launch {
+ if (accept) {
+ api.authorizeFollowRequest(accountId)
+ } else {
+ api.rejectFollowRequest(accountId)
+ }.fold(
+ onSuccess = {
+ // since the follow request has been responded, the notification can be deleted
+ remove(notification)
+ },
+ onFailure = { t ->
+ Log.w(TAG, "Failed to to respond to follow request from account id $accountId.", t)
+ }
+ )
+ }
+ }
+
+ private fun updateStatusViewData(
+ statusId: String,
+ updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
+ ) {
+ val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == statusId }
+ val statusViewData = notificationData.getOrNull(position)?.statusViewData ?: return
+ notificationData[position] = notificationData[position].copy(statusViewData = updater(statusViewData))
+ currentSource?.invalidate()
+ }
+
+ companion object {
+ private const val TAG = "NotificationRequestsViewModel"
+ }
+
+ @AssistedFactory interface Factory {
+ fun create(
+ @Assisted("notificationRequestId") notificationRequestId: String,
+ @Assisted("accountId") accountId: String
+ ): NotificationRequestDetailsViewModel
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt
index e86286df1..aab700ce7 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt
@@ -17,10 +17,10 @@ package com.keylesspalace.tusky.components.preference
import android.content.Intent
import android.graphics.Color
+import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import android.util.Log
-import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference
import androidx.preference.PreferenceFragmentCompat
@@ -38,6 +38,7 @@ import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
+import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity
import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account
@@ -54,9 +55,8 @@ import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.getInitialLanguages
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getTuskyDisplayName
-import com.keylesspalace.tusky.util.makeIcon
+import com.keylesspalace.tusky.util.icon
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
-import com.keylesspalace.tusky.util.unsafeLazy
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@@ -79,12 +79,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
@Inject
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
- private val iconSize by unsafeLazy {
- resources.getDimensionPixelSize(
- R.dimen.preference_icon_size
- )
- }
-
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = requireContext()
makePreferenceScreen {
@@ -102,7 +96,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference {
setTitle(R.string.title_tab_preferences)
- setIcon(R.drawable.ic_tabs)
+ icon = icon(R.drawable.ic_tabs)
setOnPreferenceClickListener {
val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivityWithSlideInAnimation(intent)
@@ -112,7 +106,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference {
setTitle(R.string.title_followed_hashtags)
- setIcon(R.drawable.ic_hashtag)
+ icon = icon(R.drawable.ic_hashtag)
setOnPreferenceClickListener {
val intent = Intent(context, FollowedTagsActivity::class.java)
activity?.startActivityWithSlideInAnimation(intent)
@@ -122,7 +116,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference {
setTitle(R.string.action_view_mutes)
- setIcon(R.drawable.ic_mute_24dp)
+ icon = icon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener {
val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.MUTES)
@@ -133,10 +127,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference {
setTitle(R.string.action_view_blocks)
- icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply {
- sizeRes = R.dimen.preference_icon_size
- colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
- }
+ icon = icon(GoogleMaterial.Icon.gmd_block)
setOnPreferenceClickListener {
val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.BLOCKS)
@@ -147,7 +138,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference {
setTitle(R.string.title_domain_mutes)
- setIcon(R.drawable.ic_mute_24dp)
+ icon = icon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener {
val intent = Intent(context, DomainBlocksActivity::class.java)
activity?.startActivityWithSlideInAnimation(intent)
@@ -158,7 +149,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
if (currentAccountNeedsMigration(accountManager)) {
preference {
setTitle(R.string.title_migration_relogin)
- setIcon(R.drawable.ic_logout)
+ icon = icon(R.drawable.ic_logout)
setOnPreferenceClickListener {
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
activity?.startActivityWithSlideInAnimation(intent)
@@ -169,7 +160,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference {
setTitle(R.string.pref_title_timeline_filters)
- setIcon(R.drawable.ic_filter_24dp)
+ icon = icon(R.drawable.ic_filter_24dp)
setOnPreferenceClickListener {
launchFilterActivity()
true
@@ -186,13 +177,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setSummaryProvider { entry }
val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC
value = visibility.stringValue
- setIcon(getIconForVisibility(visibility))
+ icon = getIconForVisibility(visibility)
isPersistent = false // its saved to the account and shouldn't be in shared preferences
setOnPreferenceChangeListener { _, newValue ->
- val icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String))
- setIcon(icon)
+ icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String))
if (accountManager.activeAccount?.defaultReplyPrivacy == DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY) {
- findPreference(PrefKeys.DEFAULT_REPLY_PRIVACY)?.setIcon(icon)
+ findPreference(PrefKeys.DEFAULT_REPLY_PRIVACY)?.icon = icon
}
syncWithServer(visibility = newValue)
true
@@ -210,11 +200,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setSummaryProvider { entry }
val visibility = activeAccount.defaultReplyPrivacy
value = visibility.stringValue
- setIcon(getIconForVisibility(visibility.toVisibilityOr(activeAccount.defaultPostPrivacy)))
+ icon = getIconForVisibility(visibility.toVisibilityOr(activeAccount.defaultPostPrivacy))
isPersistent = false // its saved to the account and shouldn't be in shared preferences
setOnPreferenceChangeListener { _, newValue ->
val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String)
- setIcon(getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy)))
+ icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy))
activeAccount.defaultReplyPrivacy = newVisibility
accountManager.saveAccount(activeAccount)
viewLifecycleOwner.lifecycleScope.launch {
@@ -225,6 +215,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
}
preference {
setSummary(R.string.pref_default_reply_privacy_explanation)
+ shouldDisableView = false
isEnabled = false
}
}
@@ -242,7 +233,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
entryValues = (listOf("") + locales.map { it.language }).toTypedArray()
key = PrefKeys.DEFAULT_POST_LANGUAGE
isSingleLineTitle = false
- icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize)
+ icon = icon(GoogleMaterial.Icon.gmd_translate)
value = accountManager.activeAccount?.defaultPostLanguage.orEmpty()
isPersistent = false // This will be entirely server-driven
setSummaryProvider { entry }
@@ -255,14 +246,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
switchPreference {
setTitle(R.string.pref_default_media_sensitivity)
- setIcon(R.drawable.ic_eye_24dp)
+ icon = icon(R.drawable.ic_eye_24dp)
key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY
isSingleLineTitle = false
- val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity ?: false
+ val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity == true
setDefaultValue(sensitivity)
- setIcon(getIconForSensitivity(sensitivity))
+ icon = getIconForSensitivity(sensitivity)
setOnPreferenceChangeListener { _, newValue ->
- setIcon(getIconForSensitivity(newValue as Boolean))
+ icon = getIconForSensitivity(newValue as Boolean)
syncWithServer(sensitive = newValue)
true
}
@@ -300,6 +291,16 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
fragment = TabFilterPreferencesFragment::class.qualifiedName
}
}
+ preference {
+ setTitle(R.string.notification_policies_title)
+ setOnPreferenceClickListener {
+ activity?.let {
+ val intent = NotificationPoliciesActivity.newIntent(it)
+ it.startActivityWithSlideInAnimation(intent)
+ }
+ true
+ }
+ }
}
}
@@ -357,25 +358,21 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
}
}
- @DrawableRes
- private fun getIconForVisibility(visibility: Status.Visibility): Int {
- return when (visibility) {
+ private fun getIconForVisibility(visibility: Status.Visibility): Drawable? {
+ val iconRes = when (visibility) {
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
-
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
-
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
-
else -> R.drawable.ic_public_24dp
}
+ return icon(iconRes)
}
- @DrawableRes
- private fun getIconForSensitivity(sensitive: Boolean): Int {
+ private fun getIconForSensitivity(sensitive: Boolean): Drawable? {
return if (sensitive) {
- R.drawable.ic_hide_media_24dp
+ icon(R.drawable.ic_hide_media_24dp)
} else {
- R.drawable.ic_eye_24dp
+ icon(R.drawable.ic_eye_24dp)
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
index 3ca6ca6a0..58f7ee6c2 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
@@ -32,10 +32,8 @@ import com.keylesspalace.tusky.settings.sliderPreference
import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.deserialize
-import com.keylesspalace.tusky.util.makeIcon
+import com.keylesspalace.tusky.util.icon
import com.keylesspalace.tusky.util.serialize
-import com.keylesspalace.tusky.util.unsafeLazy
-import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import dagger.hilt.android.AndroidEntryPoint
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
@@ -50,12 +48,6 @@ class PreferencesFragment : PreferenceFragmentCompat() {
@Inject
lateinit var localeManager: LocaleManager
- private val iconSize by unsafeLazy {
- resources.getDimensionPixelSize(
- R.dimen.preference_icon_size
- )
- }
-
enum class ReadingOrder {
/** User scrolls up, reading statuses oldest to newest */
OLDEST_FIRST,
@@ -86,12 +78,12 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.APP_THEME
setSummaryProvider { entry }
setTitle(R.string.pref_title_app_theme)
- icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
+ icon = icon(GoogleMaterial.Icon.gmd_palette)
}
emojiPreference(requireActivity()) {
setTitle(R.string.emoji_style)
- icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
+ icon = icon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
}
listPreference {
@@ -101,7 +93,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager
setSummaryProvider { entry }
setTitle(R.string.pref_title_language)
- icon = makeIcon(GoogleMaterial.Icon.gmd_translate)
+ icon = icon(GoogleMaterial.Icon.gmd_translate)
preferenceDataStore = localeManager
}
@@ -113,9 +105,9 @@ class PreferencesFragment : PreferenceFragmentCompat() {
stepSize = 5F
setTitle(R.string.pref_ui_text_size)
format = "%.0f%%"
- decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out)
- incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in)
- icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
+ decrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_out)
+ incrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_in)
+ icon = icon(GoogleMaterial.Icon.gmd_format_size)
}
listPreference {
@@ -125,7 +117,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.STATUS_TEXT_SIZE
setSummaryProvider { entry }
setTitle(R.string.pref_post_text_size)
- icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
+ icon = icon(GoogleMaterial.Icon.gmd_format_size)
}
listPreference {
@@ -135,7 +127,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.READING_ORDER
setSummaryProvider { entry }
setTitle(R.string.pref_title_reading_order)
- icon = makeIcon(GoogleMaterial.Icon.gmd_sort)
+ icon = icon(GoogleMaterial.Icon.gmd_sort)
}
listPreference {
@@ -182,7 +174,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.SHOW_BOT_OVERLAY
setTitle(R.string.pref_title_bot_overlay)
isSingleLineTitle = false
- setIcon(R.drawable.ic_bot_24dp)
+ icon = icon(R.drawable.ic_bot_24dp)
}
switchPreference {
@@ -309,10 +301,6 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
}
- private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable {
- return makeIcon(requireContext(), icon, iconSize)
- }
-
override fun onResume() {
super.onResume()
requireActivity().setTitle(R.string.action_view_preferences)
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt
new file mode 100644
index 000000000..06d5b3490
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt
@@ -0,0 +1,87 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.preference.notificationpolicies
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
+import com.google.android.material.snackbar.Snackbar
+import com.keylesspalace.tusky.BaseActivity
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ActivityNotificationPolicyBinding
+import com.keylesspalace.tusky.usecase.NotificationPolicyState
+import com.keylesspalace.tusky.util.getErrorString
+import com.keylesspalace.tusky.util.viewBinding
+import com.keylesspalace.tusky.util.visible
+import dagger.hilt.android.AndroidEntryPoint
+import kotlin.getValue
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class NotificationPoliciesActivity : BaseActivity() {
+
+ private val viewModel: NotificationPoliciesViewModel by viewModels()
+
+ private val binding by viewBinding(ActivityNotificationPolicyBinding::inflate)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(binding.root)
+
+ setSupportActionBar(binding.includedToolbar.toolbar)
+ supportActionBar?.run {
+ setTitle(R.string.notification_policies_title)
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowHomeEnabled(true)
+ }
+
+ lifecycleScope.launch {
+ viewModel.state.collect { state ->
+ binding.progressBar.visible(state is NotificationPolicyState.Loading)
+ binding.preferenceFragment.visible(state is NotificationPolicyState.Loaded)
+ binding.messageView.visible(state !is NotificationPolicyState.Loading && state !is NotificationPolicyState.Loaded)
+ when (state) {
+ is NotificationPolicyState.Loading -> { }
+
+ is NotificationPolicyState.Error ->
+ binding.messageView.setup(state.throwable) { viewModel.loadPolicy() }
+
+ is NotificationPolicyState.Loaded -> { }
+
+ NotificationPolicyState.Unsupported ->
+ binding.messageView.setup(R.drawable.errorphant_error, R.string.notification_policies_not_supported) { viewModel.loadPolicy() }
+ }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.error.collect { error ->
+ Snackbar.make(
+ binding.root,
+ error.getErrorString(this@NotificationPoliciesActivity),
+ LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+
+ companion object {
+ fun newIntent(context: Context) = Intent(context, NotificationPoliciesActivity::class.java)
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt
new file mode 100644
index 000000000..1a577e548
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt
@@ -0,0 +1,119 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.preference.notificationpolicies
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceFragmentCompat
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.settings.makePreferenceScreen
+import com.keylesspalace.tusky.settings.preferenceCategory
+import com.keylesspalace.tusky.usecase.NotificationPolicyState
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class NotificationPoliciesFragment : PreferenceFragmentCompat() {
+
+ val viewModel: NotificationPoliciesViewModel by activityViewModels()
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ makePreferenceScreen {
+ preferenceCategory(title = R.string.notification_policies_filter_out) { category ->
+ category.isIconSpaceReserved = false
+
+ notificationPolicyPreference {
+ setTitle(R.string.notification_policies_filter_dont_follow_title)
+ setSummary(R.string.notification_policies_filter_dont_follow_description)
+ key = KEY_NOT_FOLLOWING
+ setOnPreferenceChangeListener { _, newValue ->
+ viewModel.updatePolicy(forNotFollowing = newValue as String)
+ true
+ }
+ }
+
+ notificationPolicyPreference {
+ setTitle(R.string.notification_policies_filter_not_following_title)
+ setSummary(R.string.notification_policies_filter_not_following_description)
+ key = KEY_NOT_FOLLOWERS
+ setOnPreferenceChangeListener { _, newValue ->
+ viewModel.updatePolicy(forNotFollowers = newValue as String)
+ true
+ }
+ }
+
+ notificationPolicyPreference {
+ setTitle(R.string.unknown_notification_filter_new_accounts_title)
+ setSummary(R.string.unknown_notification_filter_new_accounts_description)
+ key = KEY_NEW_ACCOUNTS
+ setOnPreferenceChangeListener { _, newValue ->
+ viewModel.updatePolicy(forNewAccounts = newValue as String)
+ true
+ }
+ }
+
+ notificationPolicyPreference {
+ setTitle(R.string.unknown_notification_filter_unsolicited_private_mentions_title)
+ setSummary(R.string.unknown_notification_filter_unsolicited_private_mentions_description)
+ key = KEY_PRIVATE_MENTIONS
+ setOnPreferenceChangeListener { _, newValue ->
+ viewModel.updatePolicy(forPrivateMentions = newValue as String)
+ true
+ }
+ }
+
+ notificationPolicyPreference {
+ setTitle(R.string.unknown_notification_filter_moderated_accounts)
+ setSummary(R.string.unknown_notification_filter_moderated_accounts_description)
+ key = KEY_LIMITED_ACCOUNTS
+ setOnPreferenceChangeListener { _, newValue ->
+ viewModel.updatePolicy(forLimitedAccounts = newValue as String)
+ true
+ }
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { state ->
+ if (state is NotificationPolicyState.Loaded) {
+ findPreference(KEY_NOT_FOLLOWING)?.value = state.policy.forNotFollowing.name.lowercase()
+ findPreference(KEY_NOT_FOLLOWERS)?.value = state.policy.forNotFollowers.name.lowercase()
+ findPreference(KEY_NEW_ACCOUNTS)?.value = state.policy.forNewAccounts.name.lowercase()
+ findPreference(KEY_PRIVATE_MENTIONS)?.value = state.policy.forPrivateMentions.name.lowercase()
+ findPreference(KEY_LIMITED_ACCOUNTS)?.value = state.policy.forLimitedAccounts.name.lowercase()
+ }
+ }
+ }
+ }
+
+ companion object {
+ fun newInstance(): NotificationPoliciesFragment {
+ return NotificationPoliciesFragment()
+ }
+
+ private const val KEY_NOT_FOLLOWING = "NOT_FOLLOWING"
+ private const val KEY_NOT_FOLLOWERS = "NOT_FOLLOWERS"
+ private const val KEY_NEW_ACCOUNTS = "NEW_ACCOUNTS"
+ private const val KEY_PRIVATE_MENTIONS = "PRIVATE MENTIONS"
+ private const val KEY_LIMITED_ACCOUNTS = "LIMITED_ACCOUNTS"
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt
new file mode 100644
index 000000000..51a5d99e7
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt
@@ -0,0 +1,81 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.preference.notificationpolicies
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import at.connyduck.calladapter.networkresult.onFailure
+import com.keylesspalace.tusky.usecase.NotificationPolicyState
+import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+
+@HiltViewModel
+class NotificationPoliciesViewModel @Inject constructor(
+ private val usecase: NotificationPolicyUsecase
+) : ViewModel() {
+
+ val state: StateFlow = usecase.state
+
+ private val _error = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val error: SharedFlow = _error.asSharedFlow()
+
+ init {
+ loadPolicy()
+ }
+
+ fun loadPolicy() {
+ viewModelScope.launch {
+ usecase.getNotificationPolicy()
+ }
+ }
+
+ fun updatePolicy(
+ forNotFollowing: String? = null,
+ forNotFollowers: String? = null,
+ forNewAccounts: String? = null,
+ forPrivateMentions: String? = null,
+ forLimitedAccounts: String? = null
+ ) {
+ viewModelScope.launch {
+ usecase.updatePolicy(
+ forNotFollowing = forNotFollowing,
+ forNotFollowers = forNotFollowers,
+ forNewAccounts = forNewAccounts,
+ forPrivateMentions = forPrivateMentions,
+ forLimitedAccounts = forLimitedAccounts
+ ).onFailure { error ->
+ Log.w(TAG, "failed to update notifications policy", error)
+ _error.emit(error)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "NotificationPoliciesViewModel"
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt
new file mode 100644
index 000000000..fbccafd19
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt
@@ -0,0 +1,48 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.components.preference.notificationpolicies
+
+import android.content.Context
+import android.widget.TextView
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceViewHolder
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.settings.PreferenceParent
+
+class NotificationPolicyPreference(
+ context: Context
+) : ListPreference(context) {
+
+ init {
+ widgetLayoutResource = R.layout.preference_notification_policy
+ setEntries(R.array.notification_policy_options)
+ setEntryValues(R.array.notification_policy_value)
+ isIconSpaceReserved = false
+ }
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder) {
+ super.onBindViewHolder(holder)
+ val switchView: TextView = holder.findViewById(R.id.notification_policy_value) as TextView
+ switchView.text = entries.getOrNull(findIndexOfValue(value))
+ }
+}
+
+inline fun PreferenceParent.notificationPolicyPreference(builder: NotificationPolicyPreference.() -> Unit): NotificationPolicyPreference {
+ val pref = NotificationPolicyPreference(context)
+ builder(pref)
+ addPref(pref)
+ return pref
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt
index 2fc8a258b..a3e426ea8 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt
@@ -26,7 +26,8 @@ data class Notification(
val id: String,
val account: TimelineAccount,
val status: Status? = null,
- val report: Report? = null
+ val report: Report? = null,
+ val filtered: Boolean = false,
) {
/** From https://docs.joinmastodon.org/entities/Notification/#type */
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt
new file mode 100644
index 000000000..42ab7b101
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt
@@ -0,0 +1,47 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.entity
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class NotificationPolicy(
+ @Json(name = "for_not_following") val forNotFollowing: State,
+ @Json(name = "for_not_followers") val forNotFollowers: State,
+ @Json(name = "for_new_accounts") val forNewAccounts: State,
+ @Json(name = "for_private_mentions") val forPrivateMentions: State,
+ @Json(name = "for_limited_accounts") val forLimitedAccounts: State,
+ val summary: Summary
+) {
+ @JsonClass(generateAdapter = false)
+ enum class State {
+ @Json(name = "accept")
+ ACCEPT,
+
+ @Json(name = "filter")
+ FILTER,
+
+ @Json(name = "drop")
+ DROP
+ }
+
+ @JsonClass(generateAdapter = true)
+ data class Summary(
+ @Json(name = "pending_requests_count") val pendingRequestsCount: Int,
+ @Json(name = "pending_notifications_count") val pendingNotificationsCount: Int
+ )
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt
new file mode 100644
index 000000000..bfea79c9a
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt
@@ -0,0 +1,26 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.entity
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class NotificationRequest(
+ val id: String,
+ val account: Account,
+ @Json(name = "notifications_count") val notificationsCount: String
+)
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
index 39cbd5008..9c5507e90 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
@@ -36,6 +36,8 @@ import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.entity.MediaUploadResult
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.NotificationPolicy
+import com.keylesspalace.tusky.entity.NotificationRequest
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
@@ -150,7 +152,9 @@ interface MastodonApi {
/** Maximum number of results to return. Defaults to 15, max is 30 */
@Query("limit") limit: Int? = null,
/** Types to excludes from the results */
- @Query("exclude_types[]") excludes: Set? = null
+ @Query("exclude_types[]") excludes: Set? = null,
+ /** Return only notifications received from the specified account. */
+ @Query("account_id") accountId: String? = null
): Response>
/** Fetch a single notification */
@@ -722,4 +726,31 @@ interface MastodonApi {
@Path("id") statusId: String,
@Field("lang") targetLanguage: String?
): NetworkResult
+
+ @GET("api/v2/notifications/policy")
+ suspend fun notificationPolicy(): NetworkResult
+
+ @FormUrlEncoded
+ @PATCH("api/v2/notifications/policy")
+ suspend fun updateNotificationPolicy(
+ @Field("for_not_following") forNotFollowing: String?,
+ @Field("for_not_followers") forNotFollowers: String?,
+ @Field("for_new_accounts") forNewAccounts: String?,
+ @Field("for_private_mentions") forPrivateMentions: String?,
+ @Field("for_limited_accounts") forLimitedAccounts: String?
+ ): NetworkResult
+
+ @GET("api/v1/notifications/requests")
+ suspend fun getNotificationRequests(
+ @Query("max_id") maxId: String? = null,
+ @Query("min_id") minId: String? = null,
+ @Query("since_id") sinceId: String? = null,
+ @Query("limit") limit: Int? = null
+ ): Response>
+
+ @POST("api/v1/notifications/requests/{id}/accept")
+ suspend fun acceptNotificationRequest(@Path("id") notificationId: String): NetworkResult
+
+ @POST("api/v1/notifications/requests/{id}/dismiss")
+ suspend fun dismissNotificationRequest(@Path("id") notificationId: String): NetworkResult
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt
new file mode 100644
index 000000000..bac350285
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt
@@ -0,0 +1,74 @@
+package com.keylesspalace.tusky.usecase
+
+import at.connyduck.calladapter.networkresult.NetworkResult
+import at.connyduck.calladapter.networkresult.fold
+import at.connyduck.calladapter.networkresult.onSuccess
+import com.keylesspalace.tusky.entity.NotificationPolicy
+import com.keylesspalace.tusky.network.MastodonApi
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import retrofit2.HttpException
+
+class NotificationPolicyUsecase @Inject constructor(
+ private val api: MastodonApi
+) {
+
+ private val _state: MutableStateFlow = MutableStateFlow(NotificationPolicyState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+
+ suspend fun getNotificationPolicy() {
+ _state.value.let { state ->
+ if (state is NotificationPolicyState.Loaded) {
+ _state.value = state.copy(refreshing = true)
+ } else {
+ _state.value = NotificationPolicyState.Loading
+ }
+ }
+
+ api.notificationPolicy().fold(
+ { policy ->
+ _state.value = NotificationPolicyState.Loaded(refreshing = false, policy = policy)
+ },
+ { t ->
+ if (t is HttpException && t.code() == 404) {
+ _state.value = NotificationPolicyState.Unsupported
+ } else {
+ _state.value = NotificationPolicyState.Error(t)
+ }
+ }
+ )
+ }
+
+ suspend fun updatePolicy(
+ forNotFollowing: String? = null,
+ forNotFollowers: String? = null,
+ forNewAccounts: String? = null,
+ forPrivateMentions: String? = null,
+ forLimitedAccounts: String? = null
+ ): NetworkResult {
+ return api.updateNotificationPolicy(
+ forNotFollowing = forNotFollowing,
+ forNotFollowers = forNotFollowers,
+ forNewAccounts = forNewAccounts,
+ forPrivateMentions = forPrivateMentions,
+ forLimitedAccounts = forLimitedAccounts
+ ).onSuccess { notificationPolicy ->
+ _state.value = NotificationPolicyState.Loaded(false, notificationPolicy)
+ }
+ }
+}
+
+sealed interface NotificationPolicyState {
+
+ data object Loading : NotificationPolicyState
+ data object Unsupported : NotificationPolicyState
+ data class Error(
+ val throwable: Throwable
+ ) : NotificationPolicyState
+ data class Loaded(
+ val refreshing: Boolean,
+ val policy: NotificationPolicy
+ ) : NotificationPolicyState
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt
index 3cb34fe46..334f12fc4 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt
@@ -15,9 +15,10 @@
package com.keylesspalace.tusky.util
-import android.content.Context
import android.graphics.Color
-import androidx.annotation.Px
+import android.graphics.drawable.Drawable
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.mikepenz.iconics.IconicsDrawable
@@ -25,9 +26,19 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
-fun makeIcon(context: Context, icon: GoogleMaterial.Icon, @Px iconSize: Int): IconicsDrawable {
+fun PreferenceFragmentCompat.icon(icon: GoogleMaterial.Icon): IconicsDrawable {
+ val context = requireContext()
return IconicsDrawable(context, icon).apply {
- sizePx = iconSize
+ sizePx = context.resources.getDimensionPixelSize(
+ R.dimen.preference_icon_size
+ )
colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
}
}
+
+fun PreferenceFragmentCompat.icon(icon: Int): Drawable? {
+ val context = requireContext()
+ return AppCompatResources.getDrawable(context, icon)?.apply {
+ setTint(MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK))
+ }
+}
diff --git a/app/src/main/res/drawable/badge_background.xml b/app/src/main/res/drawable/badge_background.xml
new file mode 100644
index 000000000..96bc6bfbd
--- /dev/null
+++ b/app/src/main/res/drawable/badge_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_notification_policy.xml b/app/src/main/res/layout/activity_notification_policy.xml
new file mode 100644
index 000000000..0caaefde9
--- /dev/null
+++ b/app/src/main/res/layout/activity_notification_policy.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_notification_request_details.xml b/app/src/main/res/layout/activity_notification_request_details.xml
new file mode 100644
index 000000000..662524a33
--- /dev/null
+++ b/app/src/main/res/layout/activity_notification_request_details.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_notification_requests.xml b/app/src/main/res/layout/activity_notification_requests.xml
new file mode 100644
index 000000000..85e8bcf1a
--- /dev/null
+++ b/app/src/main/res/layout/activity_notification_requests.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_notification_request_details.xml b/app/src/main/res/layout/fragment_notification_request_details.xml
new file mode 100644
index 000000000..b65878284
--- /dev/null
+++ b/app/src/main/res/layout/fragment_notification_request_details.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_filtered_notifications_info.xml b/app/src/main/res/layout/item_filtered_notifications_info.xml
new file mode 100644
index 000000000..fea3dccd7
--- /dev/null
+++ b/app/src/main/res/layout/item_filtered_notifications_info.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_notification_request.xml b/app/src/main/res/layout/item_notification_request.xml
new file mode 100644
index 000000000..d9b0bc0e8
--- /dev/null
+++ b/app/src/main/res/layout/item_notification_request.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/preference_notification_policy.xml b/app/src/main/res/layout/preference_notification_policy.xml
new file mode 100644
index 000000000..ca5a06e06
--- /dev/null
+++ b/app/src/main/res/layout/preference_notification_policy.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/menu/activity_notification_requests.xml b/app/src/main/res/menu/activity_notification_requests.xml
new file mode 100644
index 000000000..5b9a2944f
--- /dev/null
+++ b/app/src/main/res/menu/activity_notification_requests.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 17dc3c9d7..532e2617e 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -267,4 +267,10 @@
(🔗 %1$s)
+
+ - accept
+ - filter
+ - drop
+
+
diff --git a/app/src/main/res/values/string-arrays.xml b/app/src/main/res/values/string-arrays.xml
index 6ceac0a81..93457f042 100644
--- a/app/src/main/res/values/string-arrays.xml
+++ b/app/src/main/res/values/string-arrays.xml
@@ -57,4 +57,10 @@
- @string/list_reply_policy_list
- @string/list_reply_policy_followed
+
+
+ - @string/notification_policy_accept
+ - @string/notification_policy_filter
+ - @string/notification_policy_ignore
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 59955747b..5eef1815b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -865,4 +865,34 @@
Show replies to
Unknown notification type
+
+ Notification Policies
+ Manage notifications from…
+
+ People you don\'t follow
+ Until you manually approve them
+ People not following you
+ Including people who have been following you fewer than 3 days
+ New accounts
+ Created within the past 30 days
+ Unsolicited private mentions
+ Filtered unless it\'s in reply to your own mention or if you follow the sender
+ Moderated accounts
+ Limited by server moderators
+
+ This feature is only supported on Mastodon servers running v4.3.0 or later.
+ Filtered notifications
+ Notifications from %1$d people you may know
+
+ Accept
+ Filter
+ Ignore
+ Filtered Notifications
+ Accept notification request
+ Dismiss notification request
+ Open settings
+ Notifications from %1$s
+ Accept
+ Dismiss
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 69f65a9b1..ea9e8c985 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -178,7 +178,6 @@
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt
index 4d89f45ba..2844d953a 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt
@@ -80,7 +80,7 @@ class NotificationsRemoteMediatorTest {
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
- onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
+ onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
},
db = db,
excludes = emptySet()
@@ -99,7 +99,7 @@ class NotificationsRemoteMediatorTest {
val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager,
api = mock {
- onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
+ onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
},
db = db,
excludes = emptySet()