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