Browse Source

Notification policy (#4768)

This was so much work wow. I think it works pretty well and is the best
compromise between all the alternative we considered. Yes the
pull-to-refreh on the notifications works slightly different now when
the new bar is visible, but I don't think there is a way around that.

Things I plan to do later, i.e. not as part of this PR or release:
- Cache the notification policy summary for better offline behavior and
less view shifting when it loads
- try to reduce some of the code duplications that are now in there
- if there is user demand, add a "legacy mode" setting where this
feature is disabled even if the server would support it

closes #4331
closes #4550 as won't do
closes #4712 as won't do

<img
src="https://github.com/user-attachments/assets/de322d3c-3775-41e7-be57-28ab7fbaecdf"
width="240"/> <img
src="https://github.com/user-attachments/assets/1ce958a4-4f15-484c-a337-5ad93f36046c"
width="240"/> <img
src="https://github.com/user-attachments/assets/98b0482b-1c05-4c99-a371-f7f4d8a69abd"
width="240"/>
pull/4784/head
Konrad Pozniak 1 year ago committed by GitHub
parent
commit
cd57352cbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      app/src/main/AndroidManifest.xml
  2. 73
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt
  3. 17
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt
  4. 74
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
  5. 2
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt
  6. 12
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt
  7. 204
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt
  8. 92
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt
  9. 35
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt
  10. 73
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt
  11. 123
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt
  12. 105
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt
  13. 296
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt
  14. 35
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt
  15. 84
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt
  16. 268
      app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt
  17. 77
      app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt
  18. 32
      app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt
  19. 87
      app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt
  20. 119
      app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt
  21. 81
      app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt
  22. 48
      app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt
  23. 3
      app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt
  24. 47
      app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt
  25. 26
      app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt
  26. 33
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  27. 74
      app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt
  28. 19
      app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt
  29. 6
      app/src/main/res/drawable/badge_background.xml
  30. 31
      app/src/main/res/layout/activity_notification_policy.xml
  31. 52
      app/src/main/res/layout/activity_notification_request_details.xml
  32. 40
      app/src/main/res/layout/activity_notification_requests.xml
  33. 25
      app/src/main/res/layout/fragment_notification_request_details.xml
  34. 46
      app/src/main/res/layout/item_filtered_notifications_info.xml
  35. 95
      app/src/main/res/layout/item_notification_request.xml
  36. 6
      app/src/main/res/layout/preference_notification_policy.xml
  37. 8
      app/src/main/res/menu/activity_notification_requests.xml
  38. 6
      app/src/main/res/values/donottranslate.xml
  39. 6
      app/src/main/res/values/string-arrays.xml
  40. 30
      app/src/main/res/values/strings.xml
  41. 1
      app/src/main/res/values/styles.xml
  42. 4
      app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt

3
app/src/main/AndroidManifest.xml

@ -149,6 +149,9 @@
<activity android:name=".components.drafts.DraftsActivity" /> <activity android:name=".components.drafts.DraftsActivity" />
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity" <activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity android:name=".components.preference.notificationpolicies.NotificationPoliciesActivity"/>
<activity android:name=".components.notifications.requests.NotificationRequestsActivity"/>
<activity android:name=".components.notifications.requests.details.NotificationRequestDetailsActivity"/>
<receiver <receiver
android:name=".receiver.SendStatusBroadcastReceiver" android:name=".receiver.SendStatusBroadcastReceiver"

73
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.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 <http://www.gnu.org/licenses>. */
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<BindingHolder<ItemFilteredNotificationsInfoBinding>>() {
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<ItemFilteredNotificationsInfoBinding> {
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<ItemFilteredNotificationsInfoBinding>, 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
}
}

17
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.db.entity.TimelineAccountEntity
import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData import com.keylesspalace.tusky.viewdata.TranslationViewData
@ -52,6 +53,22 @@ fun Notification.toEntity(
loading = false 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( fun Report.toEntity(
tuskyAccountId: Long tuskyAccountId: Long
) = NotificationReportEntity( ) = NotificationReportEntity(

74
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.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent 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.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding 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.hide
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.NotificationViewData
@ -97,7 +101,8 @@ class NotificationsFragment :
private val viewModel: NotificationsViewModel by viewModels() 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 showNotificationsFilterBar: Boolean = true
private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST
@ -147,7 +152,7 @@ class NotificationsFragment :
accountActionListener = this, accountActionListener = this,
statusDisplayOptions = statusDisplayOptions statusDisplayOptions = statusDisplayOptions
) )
this.adapter = adapter this.notificationsAdapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.setAccessibilityDelegateCompat( binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate( 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 (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
@ -179,6 +189,12 @@ class NotificationsFragment :
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) 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 -> adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
@ -257,11 +273,18 @@ class NotificationsFragment :
} }
} }
} }
viewLifecycleOwner.lifecycleScope.launch {
viewModel.notificationPolicy.collect {
notificationsPolicyAdapter.updateState(it)
}
}
} }
override fun onDestroyView() { override fun onDestroyView() {
// Clear the adapter to prevent leaking the View // Clear the adapters to prevent leaking the View
adapter = null notificationsAdapter = null
notificationsPolicyAdapter = null
super.onDestroyView() super.onDestroyView()
} }
@ -273,7 +296,8 @@ class NotificationsFragment :
} }
override fun onRefresh() { override fun onRefresh() {
adapter?.refresh() notificationsAdapter?.refresh()
viewModel.loadNotificationPolicy()
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
@ -289,11 +313,11 @@ class NotificationsFragment :
} }
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { 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) viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
} }
override fun onViewReport(reportId: String?) { override fun onViewReport(reportId: String) {
requireContext().openLink( requireContext().openLink(
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
) )
@ -304,17 +328,17 @@ class NotificationsFragment :
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status) super.reply(status.status)
} }
override fun removeItem(position: Int) { override fun removeItem(position: Int) {
val notification = adapter?.peek(position) ?: return val notification = notificationsAdapter?.peek(position) ?: return
viewModel.remove(notification.id) viewModel.remove(notification.id)
} }
override fun onReblog(reblog: Boolean, position: Int) { 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) viewModel.reblog(reblog, status)
} }
@ -328,7 +352,7 @@ class NotificationsFragment :
} }
private fun onTranslate(position: Int) { private fun onTranslate(position: Int) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(status) viewModel.translate(status)
.onFailure { .onFailure {
@ -342,32 +366,32 @@ class NotificationsFragment :
} }
override fun onUntranslate(position: Int) { override fun onUntranslate(position: Int) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.untranslate(status) viewModel.untranslate(status)
} }
override fun onFavourite(favourite: Boolean, position: Int) { 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) viewModel.favorite(favourite, status)
} }
override fun onBookmark(bookmark: Boolean, position: Int) { 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) viewModel.bookmark(bookmark, status)
} }
override fun onVoteInPoll(position: Int, choices: List<Int>) { override fun onVoteInPoll(position: Int, choices: List<Int>) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.voteInPoll(choices, status) viewModel.voteInPoll(choices, status)
} }
override fun clearWarningAction(position: Int) { override fun clearWarningAction(position: Int) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.clearWarning(status) viewModel.clearWarning(status)
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.more( super.more(
status.status, status.status,
view, view,
@ -377,32 +401,32 @@ class NotificationsFragment :
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { 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) super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
} }
override fun onViewThread(position: Int) { 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) super.viewThread(status.id, status.url)
} }
override fun onOpenReblog(position: Int) { override fun onOpenReblog(position: Int) {
val status = adapter?.peek(position)?.asStatusOrNull() ?: return val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.openReblog(status.status) super.openReblog(status.status)
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { 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) viewModel.changeExpanded(expanded, status)
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { 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) viewModel.changeContentShowing(isShowing, status)
} }
override fun onLoadMore(position: Int) { override fun onLoadMore(position: Int) {
val adapter = this.adapter val adapter = this.notificationsAdapter
val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return
loadMorePosition = position loadMorePosition = position
statusIdBelowLoadMore = statusIdBelowLoadMore =
@ -411,7 +435,7 @@ class NotificationsFragment :
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { 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) viewModel.changeContentCollapsed(isCollapsed, status)
} }

2
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 import com.keylesspalace.tusky.viewdata.NotificationViewData
interface NotificationActionListener { interface NotificationActionListener {
fun onViewReport(reportId: String?) fun onViewReport(reportId: String)
} }
interface NotificationsViewHolder { interface NotificationsViewHolder {

12
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.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys 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.usecase.TimelineCases
import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.util.serialize
@ -74,6 +76,7 @@ class NotificationsViewModel @Inject constructor(
private val preferences: SharedPreferences, private val preferences: SharedPreferences,
private val filterModel: FilterModel, private val filterModel: FilterModel,
private val db: AppDatabase, private val db: AppDatabase,
private val notificationPolicyUsecase: NotificationPolicyUsecase
) : ViewModel() { ) : ViewModel() {
private val refreshTrigger = MutableStateFlow(0L) private val refreshTrigger = MutableStateFlow(0L)
@ -116,6 +119,8 @@ class NotificationsViewModel @Inject constructor(
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
val notificationPolicy: StateFlow<NotificationPolicyState> = notificationPolicyUsecase.state
init { init {
viewModelScope.launch { viewModelScope.launch {
eventHub.events.collect { event -> eventHub.events.collect { event ->
@ -134,6 +139,13 @@ class NotificationsViewModel @Inject constructor(
refreshTrigger.value++ refreshTrigger.value++
} }
} }
loadNotificationPolicy()
}
fun loadNotificationPolicy() {
viewModelScope.launch {
notificationPolicyUsecase.getNotificationPolicy()
}
} }
fun updateNotificationFilters(newFilters: Set<Notification.Type>) { fun updateNotificationFilters(newFilters: Set<Notification.Type>) {

204
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 <http://www.gnu.org/licenses>. */
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<Emoji>
)
class NotificationRequestDetailsResultContract : ActivityResultContract<NotificationRequestDetailsResultContractInput, String?>() {
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)
}
}

92
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 <http://www.gnu.org/licenses>. */
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<NotificationRequest, BindingHolder<ItemNotificationRequestBinding>>(NOTIFICATION_REQUEST_COMPARATOR) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemNotificationRequestBinding> {
val binding = ItemNotificationRequestBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BindingHolder(binding)
}
@OptIn(ExperimentalBadgeUtils::class)
override fun onBindViewHolder(holder: BindingHolder<ItemNotificationRequestBinding>, 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<NotificationRequest>() {
override fun areItemsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean =
oldItem == newItem
}
}
}

35
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 <http://www.gnu.org/licenses>. */
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<NotificationRequest>,
private val nextKey: String?
) : PagingSource<String, NotificationRequest>() {
override fun getRefreshKey(state: PagingState<String, NotificationRequest>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, NotificationRequest> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(requests.toList(), null, nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

73
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 <http://www.gnu.org/licenses>. */
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<String, NotificationRequest>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, NotificationRequest>
): 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<List<NotificationRequest>>? {
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<List<NotificationRequest>>): 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)
}
}

123
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 <http://www.gnu.org/licenses>. */
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<NotificationRequest> = 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<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val error: SharedFlow<Throwable> = _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"
}
}

105
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 <http://www.gnu.org/licenses>. */
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<NotificationRequestDetailsViewModel.Factory> { 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<Emoji> = 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<Emoji>,
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))
}
}
}

296
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 <http://www.gnu.org/licenses>. */
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<Int>) {
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)
}
}
}

35
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 <http://www.gnu.org/licenses>. */
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<NotificationViewData>,
private val nextKey: String?
) : PagingSource<String, NotificationViewData>() {
override fun getRefreshKey(state: PagingState<String, NotificationViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, NotificationViewData> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(notifications.toList(), null, nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

84
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 <http://www.gnu.org/licenses>. */
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<String, NotificationViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, NotificationViewData>
): 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<List<Notification>>? {
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<List<Notification>>): 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)
}
}

268
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 <http://www.gnu.org/licenses>. */
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<NotificationViewData.Concrete> = 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<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val error: SharedFlow<Throwable> = _error.asSharedFlow()
private val _finish = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val finish: SharedFlow<Unit> = _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<Int>, 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<Unit> {
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
}
}

77
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.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceFragmentCompat 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.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.login.LoginActivity 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.components.systemnotifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account 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.getInitialLanguages
import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getTuskyDisplayName 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.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.unsafeLazy
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -79,12 +79,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
@Inject @Inject
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
private val iconSize by unsafeLazy {
resources.getDimensionPixelSize(
R.dimen.preference_icon_size
)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = requireContext() val context = requireContext()
makePreferenceScreen { makePreferenceScreen {
@ -102,7 +96,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference { preference {
setTitle(R.string.title_tab_preferences) setTitle(R.string.title_tab_preferences)
setIcon(R.drawable.ic_tabs) icon = icon(R.drawable.ic_tabs)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, TabPreferenceActivity::class.java) val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivityWithSlideInAnimation(intent) activity?.startActivityWithSlideInAnimation(intent)
@ -112,7 +106,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference { preference {
setTitle(R.string.title_followed_hashtags) setTitle(R.string.title_followed_hashtags)
setIcon(R.drawable.ic_hashtag) icon = icon(R.drawable.ic_hashtag)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, FollowedTagsActivity::class.java) val intent = Intent(context, FollowedTagsActivity::class.java)
activity?.startActivityWithSlideInAnimation(intent) activity?.startActivityWithSlideInAnimation(intent)
@ -122,7 +116,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference { preference {
setTitle(R.string.action_view_mutes) setTitle(R.string.action_view_mutes)
setIcon(R.drawable.ic_mute_24dp) icon = icon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.MUTES) intent.putExtra("type", AccountListActivity.Type.MUTES)
@ -133,10 +127,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference { preference {
setTitle(R.string.action_view_blocks) setTitle(R.string.action_view_blocks)
icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply { icon = icon(GoogleMaterial.Icon.gmd_block)
sizeRes = R.dimen.preference_icon_size
colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
}
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.BLOCKS) intent.putExtra("type", AccountListActivity.Type.BLOCKS)
@ -147,7 +138,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference { preference {
setTitle(R.string.title_domain_mutes) setTitle(R.string.title_domain_mutes)
setIcon(R.drawable.ic_mute_24dp) icon = icon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, DomainBlocksActivity::class.java) val intent = Intent(context, DomainBlocksActivity::class.java)
activity?.startActivityWithSlideInAnimation(intent) activity?.startActivityWithSlideInAnimation(intent)
@ -158,7 +149,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
if (currentAccountNeedsMigration(accountManager)) { if (currentAccountNeedsMigration(accountManager)) {
preference { preference {
setTitle(R.string.title_migration_relogin) setTitle(R.string.title_migration_relogin)
setIcon(R.drawable.ic_logout) icon = icon(R.drawable.ic_logout)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
activity?.startActivityWithSlideInAnimation(intent) activity?.startActivityWithSlideInAnimation(intent)
@ -169,7 +160,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
preference { preference {
setTitle(R.string.pref_title_timeline_filters) setTitle(R.string.pref_title_timeline_filters)
setIcon(R.drawable.ic_filter_24dp) icon = icon(R.drawable.ic_filter_24dp)
setOnPreferenceClickListener { setOnPreferenceClickListener {
launchFilterActivity() launchFilterActivity()
true true
@ -186,13 +177,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setSummaryProvider { entry } setSummaryProvider { entry }
val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC
value = visibility.stringValue value = visibility.stringValue
setIcon(getIconForVisibility(visibility)) icon = getIconForVisibility(visibility)
isPersistent = false // its saved to the account and shouldn't be in shared preferences isPersistent = false // its saved to the account and shouldn't be in shared preferences
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String)) icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String))
setIcon(icon)
if (accountManager.activeAccount?.defaultReplyPrivacy == DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY) { if (accountManager.activeAccount?.defaultReplyPrivacy == DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY) {
findPreference<ListPreference>(PrefKeys.DEFAULT_REPLY_PRIVACY)?.setIcon(icon) findPreference<ListPreference>(PrefKeys.DEFAULT_REPLY_PRIVACY)?.icon = icon
} }
syncWithServer(visibility = newValue) syncWithServer(visibility = newValue)
true true
@ -210,11 +200,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setSummaryProvider { entry } setSummaryProvider { entry }
val visibility = activeAccount.defaultReplyPrivacy val visibility = activeAccount.defaultReplyPrivacy
value = visibility.stringValue 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 isPersistent = false // its saved to the account and shouldn't be in shared preferences
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String) val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String)
setIcon(getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy))) icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy))
activeAccount.defaultReplyPrivacy = newVisibility activeAccount.defaultReplyPrivacy = newVisibility
accountManager.saveAccount(activeAccount) accountManager.saveAccount(activeAccount)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@ -225,6 +215,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
} }
preference { preference {
setSummary(R.string.pref_default_reply_privacy_explanation) setSummary(R.string.pref_default_reply_privacy_explanation)
shouldDisableView = false
isEnabled = false isEnabled = false
} }
} }
@ -242,7 +233,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
entryValues = (listOf("") + locales.map { it.language }).toTypedArray() entryValues = (listOf("") + locales.map { it.language }).toTypedArray()
key = PrefKeys.DEFAULT_POST_LANGUAGE key = PrefKeys.DEFAULT_POST_LANGUAGE
isSingleLineTitle = false isSingleLineTitle = false
icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize) icon = icon(GoogleMaterial.Icon.gmd_translate)
value = accountManager.activeAccount?.defaultPostLanguage.orEmpty() value = accountManager.activeAccount?.defaultPostLanguage.orEmpty()
isPersistent = false // This will be entirely server-driven isPersistent = false // This will be entirely server-driven
setSummaryProvider { entry } setSummaryProvider { entry }
@ -255,14 +246,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
switchPreference { switchPreference {
setTitle(R.string.pref_default_media_sensitivity) 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 key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY
isSingleLineTitle = false isSingleLineTitle = false
val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity ?: false val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity == true
setDefaultValue(sensitivity) setDefaultValue(sensitivity)
setIcon(getIconForSensitivity(sensitivity)) icon = getIconForSensitivity(sensitivity)
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
setIcon(getIconForSensitivity(newValue as Boolean)) icon = getIconForSensitivity(newValue as Boolean)
syncWithServer(sensitive = newValue) syncWithServer(sensitive = newValue)
true true
} }
@ -300,6 +291,16 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
fragment = TabFilterPreferencesFragment::class.qualifiedName 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): Drawable? {
private fun getIconForVisibility(visibility: Status.Visibility): Int { val iconRes = when (visibility) {
return when (visibility) {
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
else -> R.drawable.ic_public_24dp else -> R.drawable.ic_public_24dp
} }
return icon(iconRes)
} }
@DrawableRes private fun getIconForSensitivity(sensitive: Boolean): Drawable? {
private fun getIconForSensitivity(sensitive: Boolean): Int {
return if (sensitive) { return if (sensitive) {
R.drawable.ic_hide_media_24dp icon(R.drawable.ic_hide_media_24dp)
} else { } else {
R.drawable.ic_eye_24dp icon(R.drawable.ic_eye_24dp)
} }
} }

32
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.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.deserialize 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.serialize
import com.keylesspalace.tusky.util.unsafeLazy
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
@ -50,12 +48,6 @@ class PreferencesFragment : PreferenceFragmentCompat() {
@Inject @Inject
lateinit var localeManager: LocaleManager lateinit var localeManager: LocaleManager
private val iconSize by unsafeLazy {
resources.getDimensionPixelSize(
R.dimen.preference_icon_size
)
}
enum class ReadingOrder { enum class ReadingOrder {
/** User scrolls up, reading statuses oldest to newest */ /** User scrolls up, reading statuses oldest to newest */
OLDEST_FIRST, OLDEST_FIRST,
@ -86,12 +78,12 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.APP_THEME key = PrefKeys.APP_THEME
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_title_app_theme) setTitle(R.string.pref_title_app_theme)
icon = makeIcon(GoogleMaterial.Icon.gmd_palette) icon = icon(GoogleMaterial.Icon.gmd_palette)
} }
emojiPreference(requireActivity()) { emojiPreference(requireActivity()) {
setTitle(R.string.emoji_style) setTitle(R.string.emoji_style)
icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied) icon = icon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
} }
listPreference { listPreference {
@ -101,7 +93,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_title_language) setTitle(R.string.pref_title_language)
icon = makeIcon(GoogleMaterial.Icon.gmd_translate) icon = icon(GoogleMaterial.Icon.gmd_translate)
preferenceDataStore = localeManager preferenceDataStore = localeManager
} }
@ -113,9 +105,9 @@ class PreferencesFragment : PreferenceFragmentCompat() {
stepSize = 5F stepSize = 5F
setTitle(R.string.pref_ui_text_size) setTitle(R.string.pref_ui_text_size)
format = "%.0f%%" format = "%.0f%%"
decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out) decrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_out)
incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in) incrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_in)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) icon = icon(GoogleMaterial.Icon.gmd_format_size)
} }
listPreference { listPreference {
@ -125,7 +117,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.STATUS_TEXT_SIZE key = PrefKeys.STATUS_TEXT_SIZE
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_post_text_size) setTitle(R.string.pref_post_text_size)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) icon = icon(GoogleMaterial.Icon.gmd_format_size)
} }
listPreference { listPreference {
@ -135,7 +127,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.READING_ORDER key = PrefKeys.READING_ORDER
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_title_reading_order) setTitle(R.string.pref_title_reading_order)
icon = makeIcon(GoogleMaterial.Icon.gmd_sort) icon = icon(GoogleMaterial.Icon.gmd_sort)
} }
listPreference { listPreference {
@ -182,7 +174,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
key = PrefKeys.SHOW_BOT_OVERLAY key = PrefKeys.SHOW_BOT_OVERLAY
setTitle(R.string.pref_title_bot_overlay) setTitle(R.string.pref_title_bot_overlay)
isSingleLineTitle = false isSingleLineTitle = false
setIcon(R.drawable.ic_bot_24dp) icon = icon(R.drawable.ic_bot_24dp)
} }
switchPreference { switchPreference {
@ -309,10 +301,6 @@ class PreferencesFragment : PreferenceFragmentCompat() {
} }
} }
private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable {
return makeIcon(requireContext(), icon, iconSize)
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().setTitle(R.string.action_view_preferences) requireActivity().setTitle(R.string.action_view_preferences)

87
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 <http://www.gnu.org/licenses>. */
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)
}
}

119
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 <http://www.gnu.org/licenses>. */
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<NotificationPolicyPreference>(KEY_NOT_FOLLOWING)?.value = state.policy.forNotFollowing.name.lowercase()
findPreference<NotificationPolicyPreference>(KEY_NOT_FOLLOWERS)?.value = state.policy.forNotFollowers.name.lowercase()
findPreference<NotificationPolicyPreference>(KEY_NEW_ACCOUNTS)?.value = state.policy.forNewAccounts.name.lowercase()
findPreference<NotificationPolicyPreference>(KEY_PRIVATE_MENTIONS)?.value = state.policy.forPrivateMentions.name.lowercase()
findPreference<NotificationPolicyPreference>(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"
}
}

81
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 <http://www.gnu.org/licenses>. */
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<NotificationPolicyState> = usecase.state
private val _error = MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val error: SharedFlow<Throwable> = _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"
}
}

48
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 <http://www.gnu.org/licenses>. */
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
}

3
app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt

@ -26,7 +26,8 @@ data class Notification(
val id: String, val id: String,
val account: TimelineAccount, val account: TimelineAccount,
val status: Status? = null, val status: Status? = null,
val report: Report? = null val report: Report? = null,
val filtered: Boolean = false,
) { ) {
/** From https://docs.joinmastodon.org/entities/Notification/#type */ /** From https://docs.joinmastodon.org/entities/Notification/#type */

47
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 <http://www.gnu.org/licenses>. */
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
)
}

26
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 <http://www.gnu.org/licenses>. */
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
)

33
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.MediaUploadResult
import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Notification 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.NotificationSubscribeResult
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
@ -150,7 +152,9 @@ interface MastodonApi {
/** Maximum number of results to return. Defaults to 15, max is 30 */ /** Maximum number of results to return. Defaults to 15, max is 30 */
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
/** Types to excludes from the results */ /** Types to excludes from the results */
@Query("exclude_types[]") excludes: Set<Notification.Type>? = null @Query("exclude_types[]") excludes: Set<Notification.Type>? = null,
/** Return only notifications received from the specified account. */
@Query("account_id") accountId: String? = null
): Response<List<Notification>> ): Response<List<Notification>>
/** Fetch a single notification */ /** Fetch a single notification */
@ -722,4 +726,31 @@ interface MastodonApi {
@Path("id") statusId: String, @Path("id") statusId: String,
@Field("lang") targetLanguage: String? @Field("lang") targetLanguage: String?
): NetworkResult<Translation> ): NetworkResult<Translation>
@GET("api/v2/notifications/policy")
suspend fun notificationPolicy(): NetworkResult<NotificationPolicy>
@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<NotificationPolicy>
@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<List<NotificationRequest>>
@POST("api/v1/notifications/requests/{id}/accept")
suspend fun acceptNotificationRequest(@Path("id") notificationId: String): NetworkResult<Unit>
@POST("api/v1/notifications/requests/{id}/dismiss")
suspend fun dismissNotificationRequest(@Path("id") notificationId: String): NetworkResult<Unit>
} }

74
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<NotificationPolicyState> = MutableStateFlow(NotificationPolicyState.Loading)
val state: StateFlow<NotificationPolicyState> = _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<NotificationPolicy> {
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
}

19
app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt

@ -15,9 +15,10 @@
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.content.Context
import android.graphics.Color 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.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.mikepenz.iconics.IconicsDrawable 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.colorInt
import com.mikepenz.iconics.utils.sizePx 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 { 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) 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))
}
}

6
app/src/main/res/drawable/badge_background.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="?attr/colorPrimary" />
</shape>

31
app/src/main/res/layout/activity_notification_policy.xml

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".components.preference.notificationpolicies.NotificationPoliciesActivity">
<include
android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/preferenceFragment"
android:name="com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:src="@drawable/errorphant_error" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

52
app/src/main/res/layout/activity_notification_request_details.xml

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.components.accountlist.AccountListActivity">
<include
android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragmentContainer"
android:name="com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?attr/colorSurface">
<Button
android:id="@+id/acceptButton"
style="@style/TuskyButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="@string/action_accept_notification_request" />
<Button
android:id="@+id/dismissButtin"
style="@style/TuskyButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:text="@string/action_dismiss_notification_request" />
</LinearLayout>
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

40
app/src/main/res/layout/activity_notification_requests.xml

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity">
<include
android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/notificationRequestsView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:itemCount="5"
tools:listitem="@layout/item_notification_request" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/notificationRequestsMessageView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="gone" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/notificationRequestsProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

25
app/src/main/res/layout/fragment_notification_request_details.xml

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/statusView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

46
app/src/main/res/layout/item_filtered_notifications_info.xml

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="14dp"
android:paddingVertical="16dp">
<TextView
android:id="@+id/notification_policy_summary_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/filtered_notifications"
android:textColor="?android:textColorSecondary"
android:textStyle="normal|bold"
app:layout_constraintEnd_toStartOf="@id/notification_policy_summary_badge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/notification_policy_summary_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toStartOf="@id/notification_policy_summary_badge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/notification_policy_summary_title"
tools:text="@string/notifications_from_people_you_may_know" />
<TextView
android:id="@+id/notification_policy_summary_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:textColor="?attr/colorOnPrimary"
android:textStyle="normal|bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="24" />
</androidx.constraintlayout.widget.ConstraintLayout>

95
app/src/main/res/layout/item_notification_request.xml

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageView
android:id="@+id/notificationRequestAvatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<TextView
android:id="@+id/notificationRequestBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:contentDescription="@string/profile_badge_bot_text"
android:paddingHorizontal="8dp"
android:textAlignment="center"
android:textColor="?attr/colorOnPrimary"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar"
app:layout_constraintEnd_toEndOf="@id/notificationRequestAvatar"
tools:text="2" />
<TextView
android:id="@+id/notificationRequestDisplayName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@id/notificationRequestUsername"
app:layout_constraintEnd_toStartOf="@id/notificationRequestDismiss"
app:layout_constraintStart_toEndOf="@id/notificationRequestAvatar"
app:layout_constraintTop_toTopOf="@id/notificationRequestAvatar"
tools:text="Display name" />
<TextView
android:id="@+id/notificationRequestUsername"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar"
app:layout_constraintEnd_toEndOf="@id/notificationRequestDisplayName"
app:layout_constraintStart_toStartOf="@id/notificationRequestDisplayName"
app:layout_constraintTop_toBottomOf="@id/notificationRequestDisplayName"
tools:text="\@username" />
<ImageButton
android:id="@+id/notificationRequestDismiss"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/dismiss_notification_request"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar"
app:layout_constraintEnd_toStartOf="@id/notificationRequestAccept"
app:layout_constraintTop_toTopOf="@id/notificationRequestAvatar"
app:srcCompat="@drawable/ic_clear_24dp" />
<ImageButton
android:id="@+id/notificationRequestAccept"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/accept_notification_request"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/notificationRequestAvatar"
app:srcCompat="@drawable/ic_check_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

6
app/src/main/res/layout/preference_notification_policy.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/notification_policy_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold" />

8
app/src/main/res/menu/activity_notification_requests.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/open_settings"
android:title="@string/open_settings"
app:showAsAction="ifRoom" />
</menu>

6
app/src/main/res/values/donottranslate.xml

@ -267,4 +267,10 @@
<string name="url_domain_notifier" translatable="false">(🔗 %1$s)</string> <string name="url_domain_notifier" translatable="false">(🔗 %1$s)</string>
<string-array name="notification_policy_value">
<item>accept</item>
<item>filter</item>
<item>drop</item>
</string-array>
</resources> </resources>

6
app/src/main/res/values/string-arrays.xml

@ -57,4 +57,10 @@
<item>@string/list_reply_policy_list</item> <item>@string/list_reply_policy_list</item>
<item>@string/list_reply_policy_followed</item> <item>@string/list_reply_policy_followed</item>
</string-array> </string-array>
<string-array name="notification_policy_options">
<item>@string/notification_policy_accept</item>
<item>@string/notification_policy_filter</item>
<item>@string/notification_policy_ignore</item>
</string-array>
</resources> </resources>

30
app/src/main/res/values/strings.xml

@ -865,4 +865,34 @@
<string name="list_reply_policy_label">Show replies to</string> <string name="list_reply_policy_label">Show replies to</string>
<string name="unknown_notification_type">Unknown notification type</string> <string name="unknown_notification_type">Unknown notification type</string>
<string name="notification_policies_title">Notification Policies</string>
<string name="notification_policies_filter_out">Manage notifications from…</string>
<string name="notification_policies_filter_dont_follow_title">People you don\'t follow</string>
<string name="notification_policies_filter_dont_follow_description">Until you manually approve them</string>
<string name="notification_policies_filter_not_following_title">People not following you</string>
<string name="notification_policies_filter_not_following_description">Including people who have been following you fewer than 3 days</string>
<string name="unknown_notification_filter_new_accounts_title">New accounts</string>
<string name="unknown_notification_filter_new_accounts_description">Created within the past 30 days</string>
<string name="unknown_notification_filter_unsolicited_private_mentions_title">Unsolicited private mentions</string>
<string name="unknown_notification_filter_unsolicited_private_mentions_description">Filtered unless it\'s in reply to your own mention or if you follow the sender</string>
<string name="unknown_notification_filter_moderated_accounts">Moderated accounts</string>
<string name="unknown_notification_filter_moderated_accounts_description">Limited by server moderators</string>
<string name="notification_policies_not_supported">This feature is only supported on Mastodon servers running v4.3.0 or later.</string>
<string name="filtered_notifications">Filtered notifications</string>
<string name="notifications_from_people_you_may_know">Notifications from %1$d people you may know</string>
<string name="notification_policy_accept">Accept</string>
<string name="notification_policy_filter">Filter</string>
<string name="notification_policy_ignore">Ignore</string>
<string name="filtered_notifications_title">Filtered Notifications</string>
<string name="accept_notification_request">Accept notification request</string>
<string name="dismiss_notification_request">Dismiss notification request</string>
<string name="open_settings">Open settings</string>
<string name="notifications_from">Notifications from %1$s</string>
<string name="action_accept_notification_request">Accept</string>
<string name="action_dismiss_notification_request">Dismiss</string>
</resources> </resources>

1
app/src/main/res/values/styles.xml

@ -178,7 +178,6 @@
</style> </style>
<style name="TuskyPreferenceTheme" parent="@style/PreferenceThemeOverlay"> <style name="TuskyPreferenceTheme" parent="@style/PreferenceThemeOverlay">
<item name="android:tint">?iconColor</item>
<item name="switchPreferenceCompatStyle">@style/TuskySwitchPreference</item> <item name="switchPreferenceCompatStyle">@style/TuskySwitchPreference</item>
</style> </style>

4
app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt

@ -80,7 +80,7 @@ class NotificationsRemoteMediatorTest {
val remoteMediator = NotificationsRemoteMediator( val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager, accountManager = accountManager,
api = mock { 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, db = db,
excludes = emptySet() excludes = emptySet()
@ -99,7 +99,7 @@ class NotificationsRemoteMediatorTest {
val remoteMediator = NotificationsRemoteMediator( val remoteMediator = NotificationsRemoteMediator(
accountManager = accountManager, accountManager = accountManager,
api = mock { api = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
}, },
db = db, db = db,
excludes = emptySet() excludes = emptySet()

Loading…
Cancel
Save