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"/>
This commit is contained in:
parent
29914f8fd9
commit
cd57352cbd
42 changed files with 2401 additions and 97 deletions
|
|
@ -149,6 +149,9 @@
|
|||
<activity android:name=".components.drafts.DraftsActivity" />
|
||||
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
|
||||
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
|
||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import com.keylesspalace.tusky.db.entity.NotificationReportEntity
|
|||
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Report
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||
|
|
@ -52,6 +53,22 @@ fun Notification.toEntity(
|
|||
loading = false
|
||||
)
|
||||
|
||||
fun Notification.toViewData(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
): NotificationViewData.Concrete = NotificationViewData.Concrete(
|
||||
id = id,
|
||||
type = type,
|
||||
account = account,
|
||||
statusViewData = status?.toViewData(
|
||||
isShowingContent = isShowingContent,
|
||||
isExpanded = isExpanded,
|
||||
isCollapsed = isCollapsed
|
||||
),
|
||||
report = report
|
||||
)
|
||||
|
||||
fun Report.toEntity(
|
||||
tuskyAccountId: Long
|
||||
) = NotificationReportEntity(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
|
@ -44,10 +45,12 @@ import at.connyduck.sparkbutton.helpers.Utils
|
|||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
||||
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
|
||||
|
|
@ -65,6 +68,7 @@ import com.keylesspalace.tusky.util.StatusProvider
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
|
@ -97,7 +101,8 @@ class NotificationsFragment :
|
|||
|
||||
private val viewModel: NotificationsViewModel by viewModels()
|
||||
|
||||
private var adapter: NotificationsPagingAdapter? = null
|
||||
private var notificationsAdapter: NotificationsPagingAdapter? = null
|
||||
private var notificationsPolicyAdapter: NotificationPolicySummaryAdapter? = null
|
||||
|
||||
private var showNotificationsFilterBar: Boolean = true
|
||||
private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST
|
||||
|
|
@ -147,7 +152,7 @@ class NotificationsFragment :
|
|||
accountActionListener = this,
|
||||
statusDisplayOptions = statusDisplayOptions
|
||||
)
|
||||
this.adapter = adapter
|
||||
this.notificationsAdapter = adapter
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||
ListStatusAccessibilityDelegate(
|
||||
|
|
@ -169,7 +174,12 @@ class NotificationsFragment :
|
|||
)
|
||||
)
|
||||
|
||||
binding.recyclerView.adapter = adapter
|
||||
val notificationsPolicyAdapter = NotificationPolicySummaryAdapter {
|
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(NotificationRequestsActivity.newIntent(requireContext()))
|
||||
}
|
||||
this.notificationsPolicyAdapter = notificationsPolicyAdapter
|
||||
|
||||
binding.recyclerView.adapter = ConcatAdapter(notificationsPolicyAdapter, notificationsAdapter)
|
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
|
|
@ -179,6 +189,12 @@ class NotificationsFragment :
|
|||
|
||||
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
|
||||
|
||||
notificationsPolicyAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
binding.recyclerView.scrollToPosition(0)
|
||||
}
|
||||
})
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
|
@ -257,11 +273,18 @@ class NotificationsFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.notificationPolicy.collect {
|
||||
notificationsPolicyAdapter.updateState(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
// Clear the adapter to prevent leaking the View
|
||||
adapter = null
|
||||
// Clear the adapters to prevent leaking the View
|
||||
notificationsAdapter = null
|
||||
notificationsPolicyAdapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
|
|
@ -273,7 +296,8 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
adapter?.refresh()
|
||||
notificationsAdapter?.refresh()
|
||||
viewModel.loadNotificationPolicy()
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
|
|
@ -289,11 +313,11 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
|
||||
val notification = adapter?.peek(position) ?: return
|
||||
val notification = notificationsAdapter?.peek(position) ?: return
|
||||
viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
|
||||
}
|
||||
|
||||
override fun onViewReport(reportId: String?) {
|
||||
override fun onViewReport(reportId: String) {
|
||||
requireContext().openLink(
|
||||
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
|
||||
)
|
||||
|
|
@ -304,17 +328,17 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
super.reply(status.status)
|
||||
}
|
||||
|
||||
override fun removeItem(position: Int) {
|
||||
val notification = adapter?.peek(position) ?: return
|
||||
val notification = notificationsAdapter?.peek(position) ?: return
|
||||
viewModel.remove(notification.id)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.reblog(reblog, status)
|
||||
}
|
||||
|
||||
|
|
@ -328,7 +352,7 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
private fun onTranslate(position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.translate(status)
|
||||
.onFailure {
|
||||
|
|
@ -342,32 +366,32 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onUntranslate(position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.untranslate(status)
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.favorite(favourite, status)
|
||||
}
|
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.bookmark(bookmark, status)
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.voteInPoll(choices, status)
|
||||
}
|
||||
|
||||
override fun clearWarningAction(position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.clearWarning(status)
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
super.more(
|
||||
status.status,
|
||||
view,
|
||||
|
|
@ -377,32 +401,32 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull()?.status ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull()?.status ?: return
|
||||
super.viewThread(status.id, status.url)
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
super.openReblog(status.status)
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.changeExpanded(expanded, status)
|
||||
}
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.changeContentShowing(isShowing, status)
|
||||
}
|
||||
|
||||
override fun onLoadMore(position: Int) {
|
||||
val adapter = this.adapter
|
||||
val adapter = this.notificationsAdapter
|
||||
val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return
|
||||
loadMorePosition = position
|
||||
statusIdBelowLoadMore =
|
||||
|
|
@ -411,7 +435,7 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.changeContentCollapsed(isCollapsed, status)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
interface NotificationActionListener {
|
||||
fun onViewReport(reportId: String?)
|
||||
fun onViewReport(reportId: String)
|
||||
}
|
||||
|
||||
interface NotificationsViewHolder {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import com.keylesspalace.tusky.entity.Notification
|
|||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyState
|
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
import com.keylesspalace.tusky.util.serialize
|
||||
|
|
@ -74,6 +76,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
private val preferences: SharedPreferences,
|
||||
private val filterModel: FilterModel,
|
||||
private val db: AppDatabase,
|
||||
private val notificationPolicyUsecase: NotificationPolicyUsecase
|
||||
) : ViewModel() {
|
||||
|
||||
private val refreshTrigger = MutableStateFlow(0L)
|
||||
|
|
@ -116,6 +119,8 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
val notificationPolicy: StateFlow<NotificationPolicyState> = notificationPolicyUsecase.state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
|
|
@ -134,6 +139,13 @@ class NotificationsViewModel @Inject constructor(
|
|||
refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
loadNotificationPolicy()
|
||||
}
|
||||
|
||||
fun loadNotificationPolicy() {
|
||||
viewModelScope.launch {
|
||||
notificationPolicyUsecase.getNotificationPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotificationFilters(newFilters: Set<Notification.Type>) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,10 @@ package com.keylesspalace.tusky.components.preference
|
|||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
|
|
@ -38,6 +38,7 @@ import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
|
|||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity
|
||||
import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
|
|
@ -54,9 +55,8 @@ import com.keylesspalace.tusky.settings.switchPreference
|
|||
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||
import com.keylesspalace.tusky.util.getLocaleList
|
||||
import com.keylesspalace.tusky.util.getTuskyDisplayName
|
||||
import com.keylesspalace.tusky.util.makeIcon
|
||||
import com.keylesspalace.tusky.util.icon
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
|
|
@ -79,12 +79,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
@Inject
|
||||
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
|
||||
|
||||
private val iconSize by unsafeLazy {
|
||||
resources.getDimensionPixelSize(
|
||||
R.dimen.preference_icon_size
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
val context = requireContext()
|
||||
makePreferenceScreen {
|
||||
|
|
@ -102,7 +96,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
preference {
|
||||
setTitle(R.string.title_tab_preferences)
|
||||
setIcon(R.drawable.ic_tabs)
|
||||
icon = icon(R.drawable.ic_tabs)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, TabPreferenceActivity::class.java)
|
||||
activity?.startActivityWithSlideInAnimation(intent)
|
||||
|
|
@ -112,7 +106,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
preference {
|
||||
setTitle(R.string.title_followed_hashtags)
|
||||
setIcon(R.drawable.ic_hashtag)
|
||||
icon = icon(R.drawable.ic_hashtag)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, FollowedTagsActivity::class.java)
|
||||
activity?.startActivityWithSlideInAnimation(intent)
|
||||
|
|
@ -122,7 +116,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
preference {
|
||||
setTitle(R.string.action_view_mutes)
|
||||
setIcon(R.drawable.ic_mute_24dp)
|
||||
icon = icon(R.drawable.ic_mute_24dp)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, AccountListActivity::class.java)
|
||||
intent.putExtra("type", AccountListActivity.Type.MUTES)
|
||||
|
|
@ -133,10 +127,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
preference {
|
||||
setTitle(R.string.action_view_blocks)
|
||||
icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply {
|
||||
sizeRes = R.dimen.preference_icon_size
|
||||
colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
|
||||
}
|
||||
icon = icon(GoogleMaterial.Icon.gmd_block)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, AccountListActivity::class.java)
|
||||
intent.putExtra("type", AccountListActivity.Type.BLOCKS)
|
||||
|
|
@ -147,7 +138,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
preference {
|
||||
setTitle(R.string.title_domain_mutes)
|
||||
setIcon(R.drawable.ic_mute_24dp)
|
||||
icon = icon(R.drawable.ic_mute_24dp)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, DomainBlocksActivity::class.java)
|
||||
activity?.startActivityWithSlideInAnimation(intent)
|
||||
|
|
@ -158,7 +149,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
if (currentAccountNeedsMigration(accountManager)) {
|
||||
preference {
|
||||
setTitle(R.string.title_migration_relogin)
|
||||
setIcon(R.drawable.ic_logout)
|
||||
icon = icon(R.drawable.ic_logout)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
|
||||
activity?.startActivityWithSlideInAnimation(intent)
|
||||
|
|
@ -169,7 +160,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
preference {
|
||||
setTitle(R.string.pref_title_timeline_filters)
|
||||
setIcon(R.drawable.ic_filter_24dp)
|
||||
icon = icon(R.drawable.ic_filter_24dp)
|
||||
setOnPreferenceClickListener {
|
||||
launchFilterActivity()
|
||||
true
|
||||
|
|
@ -186,13 +177,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
setSummaryProvider { entry }
|
||||
val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC
|
||||
value = visibility.stringValue
|
||||
setIcon(getIconForVisibility(visibility))
|
||||
icon = getIconForVisibility(visibility)
|
||||
isPersistent = false // its saved to the account and shouldn't be in shared preferences
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String))
|
||||
setIcon(icon)
|
||||
icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String))
|
||||
if (accountManager.activeAccount?.defaultReplyPrivacy == DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY) {
|
||||
findPreference<ListPreference>(PrefKeys.DEFAULT_REPLY_PRIVACY)?.setIcon(icon)
|
||||
findPreference<ListPreference>(PrefKeys.DEFAULT_REPLY_PRIVACY)?.icon = icon
|
||||
}
|
||||
syncWithServer(visibility = newValue)
|
||||
true
|
||||
|
|
@ -210,11 +200,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
setSummaryProvider { entry }
|
||||
val visibility = activeAccount.defaultReplyPrivacy
|
||||
value = visibility.stringValue
|
||||
setIcon(getIconForVisibility(visibility.toVisibilityOr(activeAccount.defaultPostPrivacy)))
|
||||
icon = getIconForVisibility(visibility.toVisibilityOr(activeAccount.defaultPostPrivacy))
|
||||
isPersistent = false // its saved to the account and shouldn't be in shared preferences
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String)
|
||||
setIcon(getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy)))
|
||||
icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy))
|
||||
activeAccount.defaultReplyPrivacy = newVisibility
|
||||
accountManager.saveAccount(activeAccount)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
|
|
@ -225,6 +215,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
preference {
|
||||
setSummary(R.string.pref_default_reply_privacy_explanation)
|
||||
shouldDisableView = false
|
||||
isEnabled = false
|
||||
}
|
||||
}
|
||||
|
|
@ -242,7 +233,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
entryValues = (listOf("") + locales.map { it.language }).toTypedArray()
|
||||
key = PrefKeys.DEFAULT_POST_LANGUAGE
|
||||
isSingleLineTitle = false
|
||||
icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_translate)
|
||||
value = accountManager.activeAccount?.defaultPostLanguage.orEmpty()
|
||||
isPersistent = false // This will be entirely server-driven
|
||||
setSummaryProvider { entry }
|
||||
|
|
@ -255,14 +246,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
switchPreference {
|
||||
setTitle(R.string.pref_default_media_sensitivity)
|
||||
setIcon(R.drawable.ic_eye_24dp)
|
||||
icon = icon(R.drawable.ic_eye_24dp)
|
||||
key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY
|
||||
isSingleLineTitle = false
|
||||
val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity ?: false
|
||||
val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity == true
|
||||
setDefaultValue(sensitivity)
|
||||
setIcon(getIconForSensitivity(sensitivity))
|
||||
icon = getIconForSensitivity(sensitivity)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
setIcon(getIconForSensitivity(newValue as Boolean))
|
||||
icon = getIconForSensitivity(newValue as Boolean)
|
||||
syncWithServer(sensitive = newValue)
|
||||
true
|
||||
}
|
||||
|
|
@ -300,6 +291,16 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
fragment = TabFilterPreferencesFragment::class.qualifiedName
|
||||
}
|
||||
}
|
||||
preference {
|
||||
setTitle(R.string.notification_policies_title)
|
||||
setOnPreferenceClickListener {
|
||||
activity?.let {
|
||||
val intent = NotificationPoliciesActivity.newIntent(it)
|
||||
it.startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,25 +358,21 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getIconForVisibility(visibility: Status.Visibility): Int {
|
||||
return when (visibility) {
|
||||
private fun getIconForVisibility(visibility: Status.Visibility): Drawable? {
|
||||
val iconRes = when (visibility) {
|
||||
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
|
||||
|
||||
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
|
||||
|
||||
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
|
||||
|
||||
else -> R.drawable.ic_public_24dp
|
||||
}
|
||||
return icon(iconRes)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getIconForSensitivity(sensitive: Boolean): Int {
|
||||
private fun getIconForSensitivity(sensitive: Boolean): Drawable? {
|
||||
return if (sensitive) {
|
||||
R.drawable.ic_hide_media_24dp
|
||||
icon(R.drawable.ic_hide_media_24dp)
|
||||
} else {
|
||||
R.drawable.ic_eye_24dp
|
||||
icon(R.drawable.ic_eye_24dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,8 @@ import com.keylesspalace.tusky.settings.sliderPreference
|
|||
import com.keylesspalace.tusky.settings.switchPreference
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
import com.keylesspalace.tusky.util.makeIcon
|
||||
import com.keylesspalace.tusky.util.icon
|
||||
import com.keylesspalace.tusky.util.serialize
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
|
||||
|
|
@ -50,12 +48,6 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
@Inject
|
||||
lateinit var localeManager: LocaleManager
|
||||
|
||||
private val iconSize by unsafeLazy {
|
||||
resources.getDimensionPixelSize(
|
||||
R.dimen.preference_icon_size
|
||||
)
|
||||
}
|
||||
|
||||
enum class ReadingOrder {
|
||||
/** User scrolls up, reading statuses oldest to newest */
|
||||
OLDEST_FIRST,
|
||||
|
|
@ -86,12 +78,12 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
key = PrefKeys.APP_THEME
|
||||
setSummaryProvider { entry }
|
||||
setTitle(R.string.pref_title_app_theme)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_palette)
|
||||
}
|
||||
|
||||
emojiPreference(requireActivity()) {
|
||||
setTitle(R.string.emoji_style)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
|
|
@ -101,7 +93,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager
|
||||
setSummaryProvider { entry }
|
||||
setTitle(R.string.pref_title_language)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_translate)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_translate)
|
||||
preferenceDataStore = localeManager
|
||||
}
|
||||
|
||||
|
|
@ -113,9 +105,9 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
stepSize = 5F
|
||||
setTitle(R.string.pref_ui_text_size)
|
||||
format = "%.0f%%"
|
||||
decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out)
|
||||
incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
|
||||
decrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_out)
|
||||
incrementIcon = icon(GoogleMaterial.Icon.gmd_zoom_in)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_format_size)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
|
|
@ -125,7 +117,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
key = PrefKeys.STATUS_TEXT_SIZE
|
||||
setSummaryProvider { entry }
|
||||
setTitle(R.string.pref_post_text_size)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_format_size)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
|
|
@ -135,7 +127,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
key = PrefKeys.READING_ORDER
|
||||
setSummaryProvider { entry }
|
||||
setTitle(R.string.pref_title_reading_order)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_sort)
|
||||
icon = icon(GoogleMaterial.Icon.gmd_sort)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
|
|
@ -182,7 +174,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
key = PrefKeys.SHOW_BOT_OVERLAY
|
||||
setTitle(R.string.pref_title_bot_overlay)
|
||||
isSingleLineTitle = false
|
||||
setIcon(R.drawable.ic_bot_24dp)
|
||||
icon = icon(R.drawable.ic_bot_24dp)
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
|
|
@ -309,10 +301,6 @@ class PreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable {
|
||||
return makeIcon(requireContext(), icon, iconSize)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().setTitle(R.string.action_view_preferences)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -26,7 +26,8 @@ data class Notification(
|
|||
val id: String,
|
||||
val account: TimelineAccount,
|
||||
val status: Status? = null,
|
||||
val report: Report? = null
|
||||
val report: Report? = null,
|
||||
val filtered: Boolean = false,
|
||||
) {
|
||||
|
||||
/** From https://docs.joinmastodon.org/entities/Notification/#type */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -36,6 +36,8 @@ import com.keylesspalace.tusky.entity.MastoList
|
|||
import com.keylesspalace.tusky.entity.MediaUploadResult
|
||||
import com.keylesspalace.tusky.entity.NewStatus
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.NotificationPolicy
|
||||
import com.keylesspalace.tusky.entity.NotificationRequest
|
||||
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
|
|
@ -150,7 +152,9 @@ interface MastodonApi {
|
|||
/** Maximum number of results to return. Defaults to 15, max is 30 */
|
||||
@Query("limit") limit: Int? = null,
|
||||
/** Types to excludes from the results */
|
||||
@Query("exclude_types[]") excludes: Set<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>>
|
||||
|
||||
/** Fetch a single notification */
|
||||
|
|
@ -722,4 +726,31 @@ interface MastodonApi {
|
|||
@Path("id") statusId: String,
|
||||
@Field("lang") targetLanguage: String?
|
||||
): 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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -15,9 +15,10 @@
|
|||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import androidx.annotation.Px
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
|
|
@ -25,9 +26,19 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
|||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizePx
|
||||
|
||||
fun makeIcon(context: Context, icon: GoogleMaterial.Icon, @Px iconSize: Int): IconicsDrawable {
|
||||
fun PreferenceFragmentCompat.icon(icon: GoogleMaterial.Icon): IconicsDrawable {
|
||||
val context = requireContext()
|
||||
return IconicsDrawable(context, icon).apply {
|
||||
sizePx = iconSize
|
||||
sizePx = context.resources.getDimensionPixelSize(
|
||||
R.dimen.preference_icon_size
|
||||
)
|
||||
colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
|
||||
}
|
||||
}
|
||||
|
||||
fun PreferenceFragmentCompat.icon(icon: Int): Drawable? {
|
||||
val context = requireContext()
|
||||
return AppCompatResources.getDrawable(context, icon)?.apply {
|
||||
setTint(MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
app/src/main/res/drawable/badge_background.xml
Normal file
6
app/src/main/res/drawable/badge_background.xml
Normal file
|
|
@ -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
Normal file
31
app/src/main/res/layout/activity_notification_policy.xml
Normal file
|
|
@ -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>
|
||||
|
|
@ -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
Normal file
40
app/src/main/res/layout/activity_notification_requests.xml
Normal file
|
|
@ -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>
|
||||
|
|
@ -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
Normal file
46
app/src/main/res/layout/item_filtered_notifications_info.xml
Normal file
|
|
@ -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
Normal file
95
app/src/main/res/layout/item_notification_request.xml
Normal file
|
|
@ -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>
|
||||
|
|
@ -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
Normal file
8
app/src/main/res/menu/activity_notification_requests.xml
Normal file
|
|
@ -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>
|
||||
|
|
@ -267,4 +267,10 @@
|
|||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -57,4 +57,10 @@
|
|||
<item>@string/list_reply_policy_list</item>
|
||||
<item>@string/list_reply_policy_followed</item>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -865,4 +865,34 @@
|
|||
<string name="list_reply_policy_label">Show replies to</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>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,6 @@
|
|||
</style>
|
||||
|
||||
<style name="TuskyPreferenceTheme" parent="@style/PreferenceThemeOverlay">
|
||||
<item name="android:tint">?iconColor</item>
|
||||
<item name="switchPreferenceCompatStyle">@style/TuskySwitchPreference</item>
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class NotificationsRemoteMediatorTest {
|
|||
val remoteMediator = NotificationsRemoteMediator(
|
||||
accountManager = accountManager,
|
||||
api = mock {
|
||||
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
|
||||
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody())
|
||||
},
|
||||
db = db,
|
||||
excludes = emptySet()
|
||||
|
|
@ -99,7 +99,7 @@ class NotificationsRemoteMediatorTest {
|
|||
val remoteMediator = NotificationsRemoteMediator(
|
||||
accountManager = accountManager,
|
||||
api = mock {
|
||||
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
|
||||
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException()
|
||||
},
|
||||
db = db,
|
||||
excludes = emptySet()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue