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:
Konrad Pozniak 2024-12-03 18:46:50 +01:00 committed by GitHub
commit cd57352cbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2401 additions and 97 deletions

View file

@ -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
}
}

View file

@ -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(

View file

@ -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)
}

View file

@ -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 {

View file

@ -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>) {

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

@ -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))
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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
}

View file

@ -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 */

View file

@ -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
)
}

View file

@ -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
)

View file

@ -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>
}

View file

@ -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
}

View file

@ -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))
}
}