various not push related notification improvements (#4929)

- support new notification type `severed_relationships`, closes
https://github.com/tuskyapp/Tusky/issues/4835, closes
https://github.com/tuskyapp/Tusky/issues/4334
- support new notification type `moderation_warning`
- the account note is now shown again for follow request and follow
notifcations (was broken since
https://github.com/tuskyapp/Tusky/pull/4026)
- closes https://github.com/tuskyapp/Tusky/issues/4571
- The "unknown notification type" notification now shows the unknown
type and a info dialog when you click it
https://chaos.social/@ConnyDuck/113601791254050485
- The notification policy banner in the notification tab is now cached
for better offline behavior (and less jumping of the list on every load)
and updates when interacting with the requests
- Fixes a bug where some notifications wouldn't be filtered correctly.
Behavior should now match Mastodon.
https://mastodon.social/@alm10965/113639206858728177
- Fixes a bug where some system notifications wouldn't have a body
- For filters and channels, report and signup notifications are now
grouped as "Admin", severed relationship events and moderation warnings
as "other". These lists are super long already.
- The icon for the "`<user>` just posted" notification is now a bell
instead of a home
- Follow requests won't be filtered by default in the notification tab.
No idea why this one got special treatment. This change will only affect
new logins and not existing ones.
- closes #4440 
- Adds info about attached media or poll to
StatusNotificationViewHolder. This is important context that has been
missing before.
- Adds (private) reply/(private) mention text above mention
notification. (Partially?) closes
https://github.com/tuskyapp/Tusky/issues/3883

Some screenshots:

![follow](https://github.com/user-attachments/assets/5f962116-c16f-4574-aae1-b1f931ce1508)

![moderation_warning](https://github.com/user-attachments/assets/55a2ee7e-ebcd-4ae8-9170-f07f9f5df5d2)

![severed_relationship](https://github.com/user-attachments/assets/a8d6b898-eb44-43b4-9b6d-3fb5f7aeb852)

![unknown](https://github.com/user-attachments/assets/c74ee33e-6926-42b1-b952-dc888b72fd27)

![unknown_info](https://github.com/user-attachments/assets/19ff11bf-aaff-4219-87e2-ea980ebbd118)

![notifications](https://github.com/user-attachments/assets/b5021cbb-f6c0-4a17-9e15-73e669504647)
This commit is contained in:
Konrad Pozniak 2025-02-24 14:53:05 +01:00 committed by GitHub
commit d0b20cf06e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 2569 additions and 402 deletions

View file

@ -446,7 +446,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
// otherwise show notification tab
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FollowRequest.name) {
val accountListIntent = AccountListActivity.newIntent(
this,
AccountListActivity.Type.FOLLOW_REQUESTS

View file

@ -132,7 +132,7 @@ class MainViewModel @Inject constructor(
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any {
it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT
it.type == Notification.Type.Mention && it.status?.visibility == Status.Visibility.DIRECT
}
if (hasDirectMessageNotification) {

View file

@ -16,7 +16,9 @@
package com.keylesspalace.tusky
import android.app.Application
import android.app.NotificationManager
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
@ -53,6 +55,9 @@ class TuskyApplication : Application(), Configuration.Provider {
@Inject
lateinit var preferences: SharedPreferences
@Inject
lateinit var notificationManager: NotificationManager
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
@ -80,6 +85,14 @@ class TuskyApplication : Application(), Configuration.Provider {
// A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one
workManager.cancelAllWorkByTag("pullNotifications")
}
if (oldVersion < 2025022001 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// delete old now unused notification channels
for (channel in notificationManager.notificationChannels) {
if (channel.id.startsWith("CHANNEL_SIGN_UP") || channel.id.startsWith("CHANNEL_REPORT")) {
notificationManager.deleteNotificationChannel(channel.id)
}
}
}
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}

View file

@ -119,5 +119,6 @@ class FollowRequestViewHolder(
}
}
itemView.setOnClickListener { listener.onViewAccount(accountId) }
binding.accountNote.setOnClickListener { listener.onViewAccount(accountId) }
}
}

View file

@ -23,7 +23,6 @@ import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
@ -40,9 +39,6 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
@ -134,16 +130,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_all_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
// don't use this on the same ViewHolder as setStatusInfoContent, will cause recycling issues as paddings are changed
protected void setPollInfo(final boolean ownPoll) {
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0);
statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
@ -159,6 +146,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
statusInfo.setVisibility(View.GONE);
}
protected TextView getStatusInfo() {
return statusInfo;
}
private void setupCollapsedState(boolean sensitive,
boolean expanded,
final StatusViewData.Concrete status,

View file

@ -20,15 +20,22 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.NotificationViewData
class FollowViewHolder(
private val binding: ItemFollowBinding,
private val listener: AccountActionListener,
private val linkListener: LinkListener
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
@ -42,7 +49,7 @@ class FollowViewHolder(
val context = itemView.context
val account = viewData.account
val messageTemplate =
context.getString(if (viewData.type == Notification.Type.SIGN_UP) R.string.notification_sign_up_format else R.string.notification_follow_format)
context.getString(if (viewData.type == Notification.Type.SignUp) R.string.notification_sign_up_format else R.string.notification_follow_format)
val wrappedDisplayName = account.name.unicodeWrap()
binding.notificationText.text = messageTemplate.format(wrappedDisplayName)
@ -57,16 +64,28 @@ class FollowViewHolder(
)
binding.notificationDisplayName.text = emojifiedDisplayName
if (account.note.isEmpty()) {
binding.accountNote.hide()
} else {
binding.accountNote.show()
val emojifiedNote = account.note.parseAsMastodonHtml()
.emojify(account.emojis, binding.accountNote, statusDisplayOptions.animateEmojis)
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
}
val avatarRadius = context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
loadAvatar(
account.avatar,
binding.notificationAvatar,
avatarRadius,
statusDisplayOptions.animateAvatars,
null
statusDisplayOptions.animateAvatars
)
binding.avatarBadge.visible(statusDisplayOptions.showBotOverlay && account.bot)
itemView.setOnClickListener { listener.onViewAccount(account.id) }
binding.accountNote.setOnClickListener { listener.onViewAccount(account.id) }
}
}

View file

@ -0,0 +1,47 @@
/* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.content.Intent
import androidx.core.net.toUri
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
class ModerationWarningViewHolder(
private val binding: ItemModerationWarningNotificationBinding,
private val instanceDomain: String
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
val warning = viewData.moderationWarning!!
binding.moderationWarningDescription.setText(warning.action.text)
binding.root.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, "https://$instanceDomain/disputes/strikes/${warning.id}".toUri())
binding.root.context.startActivity(intent)
}
}
}

View file

@ -20,7 +20,7 @@ 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.db.entity.NotificationPolicyEntity
import com.keylesspalace.tusky.util.BindingHolder
import java.text.NumberFormat
@ -28,9 +28,9 @@ class NotificationPolicySummaryAdapter(
private val onOpenDetails: () -> Unit
) : RecyclerView.Adapter<BindingHolder<ItemFilteredNotificationsInfoBinding>>() {
private var state: NotificationPolicyState = NotificationPolicyState.Loading
private var state: NotificationPolicyEntity? = null
fun updateState(newState: NotificationPolicyState) {
fun updateState(newState: NotificationPolicyEntity?) {
val oldShowInfo = state.shouldShowInfo()
val newShowInfo = newState.shouldShowInfo()
state = newState
@ -58,16 +58,15 @@ class NotificationPolicySummaryAdapter(
override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0
override fun onBindViewHolder(holder: BindingHolder<ItemFilteredNotificationsInfoBinding>, position: Int) {
val policySummary = (state as? NotificationPolicyState.Loaded)?.policy?.summary
if (policySummary != null) {
state?.let { policyState ->
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)
binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policyState.pendingRequestsCount)
binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policyState.pendingNotificationsCount)
}
}
private fun NotificationPolicyState.shouldShowInfo(): Boolean {
return this is NotificationPolicyState.Loaded && this.policy.summary.pendingNotificationsCount > 0
private fun NotificationPolicyEntity?.shouldShowInfo(): Boolean {
return this != null && this.pendingNotificationsCount > 0
}
}

View file

@ -38,6 +38,8 @@ fun Placeholder.toNotificationEntity(
accountId = null,
statusId = null,
reportId = null,
event = null,
moderationWarning = null,
loading = loading
)
@ -50,6 +52,8 @@ fun Notification.toEntity(
accountId = account.id,
statusId = status?.reblog?.id ?: status?.id,
reportId = report?.id,
event = event,
moderationWarning = moderationWarning,
loading = false
)
@ -66,7 +70,9 @@ fun Notification.toViewData(
isExpanded = isExpanded,
isCollapsed = isCollapsed
),
report = report
report = report,
moderationWarning = moderationWarning,
event = event
)
fun Report.toEntity(
@ -106,7 +112,9 @@ fun NotificationDataEntity.toViewData(
report.toReport(reportTargetAccount)
} else {
null
}
},
event = event,
moderationWarning = moderationWarning
)
}

View file

@ -51,10 +51,10 @@ 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.NotificationChannelData
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
import com.keylesspalace.tusky.databinding.NotificationsFilterBinding
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
@ -115,9 +115,11 @@ class NotificationsFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val activeAccount = accountManager.activeAccount ?: return
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
mediaPreviewEnabled = 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),
@ -131,8 +133,8 @@ class NotificationsFragment :
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
showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia,
openSpoiler = activeAccount.alwaysOpenSpoiler
)
binding.recyclerView.ensureBottomPadding(fab = true)
@ -149,11 +151,12 @@ class NotificationsFragment :
// Setup the RecyclerView.
binding.recyclerView.setHasFixedSize(true)
val adapter = NotificationsPagingAdapter(
accountId = accountManager.activeAccount!!.accountId,
accountId = activeAccount.accountId,
statusListener = this,
notificationActionListener = this,
accountActionListener = this,
statusDisplayOptions = statusDisplayOptions
statusDisplayOptions = statusDisplayOptions,
instanceName = activeAccount.domain
)
this.notificationsAdapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -447,8 +450,8 @@ class NotificationsFragment :
}
private fun showFilterMenu() {
val notificationTypeList = Notification.Type.visibleTypes.map { type ->
getString(type.uiString)
val notificationTypeList = NotificationChannelData.entries.map { type ->
getString(type.title)
}
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList)
@ -457,7 +460,7 @@ class NotificationsFragment :
menuBinding.buttonApply.setOnClickListener {
val checkedItems = menuBinding.listView.getCheckedItemPositions()
val excludes = Notification.Type.visibleTypes.filterIndexed { index, _ ->
val excludes = NotificationChannelData.entries.filterIndexed { index, _ ->
!checkedItems[index, false]
}
window.dismiss()
@ -467,7 +470,7 @@ class NotificationsFragment :
menuBinding.listView.setAdapter(adapter)
menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE)
Notification.Type.visibleTypes.forEachIndexed { index, type ->
NotificationChannelData.entries.forEachIndexed { index, type ->
menuBinding.listView.setItemChecked(index, !viewModel.excludes.value.contains(type))
}

View file

@ -27,7 +27,9 @@ import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
@ -57,7 +59,8 @@ class NotificationsPagingAdapter(
private var statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener,
private val notificationActionListener: NotificationActionListener,
private val accountActionListener: AccountActionListener
private val accountActionListener: AccountActionListener,
private val instanceName: String
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(NotificationsDifferCallback) {
var mediaPreviewEnabled: Boolean
@ -79,20 +82,26 @@ class NotificationsPagingAdapter(
return when (val notification = getItem(position)) {
is NotificationViewData.Concrete -> {
when (notification.type) {
Notification.Type.MENTION,
Notification.Type.POLL -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
Notification.Type.Mention,
Notification.Type.Poll -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS
}
Notification.Type.STATUS,
Notification.Type.FAVOURITE,
Notification.Type.REBLOG,
Notification.Type.UPDATE -> VIEW_TYPE_STATUS_NOTIFICATION
Notification.Type.FOLLOW,
Notification.Type.SIGN_UP -> VIEW_TYPE_FOLLOW
Notification.Type.FOLLOW_REQUEST -> VIEW_TYPE_FOLLOW_REQUEST
Notification.Type.REPORT -> VIEW_TYPE_REPORT
Notification.Type.Status,
Notification.Type.Update -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS_NOTIFICATION
}
Notification.Type.Favourite,
Notification.Type.Reblog -> VIEW_TYPE_STATUS_NOTIFICATION
Notification.Type.Follow,
Notification.Type.SignUp -> VIEW_TYPE_FOLLOW
Notification.Type.FollowRequest -> VIEW_TYPE_FOLLOW_REQUEST
Notification.Type.Report -> VIEW_TYPE_REPORT
Notification.Type.SeveredRelationship -> VIEW_TYPE_SEVERED_RELATIONSHIP
Notification.Type.ModerationWarning -> VIEW_TYPE_MODERATION_WARNING
else -> VIEW_TYPE_UNKNOWN
}
}
@ -119,7 +128,8 @@ class NotificationsPagingAdapter(
)
VIEW_TYPE_FOLLOW -> FollowViewHolder(
ItemFollowBinding.inflate(inflater, parent, false),
accountActionListener
accountActionListener,
statusListener
)
VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder(
ItemFollowRequestBinding.inflate(inflater, parent, false),
@ -136,6 +146,14 @@ class NotificationsPagingAdapter(
notificationActionListener,
accountActionListener
)
VIEW_TYPE_SEVERED_RELATIONSHIP -> SeveredRelationshipNotificationViewHolder(
ItemSeveredRelationshipNotificationBinding.inflate(inflater, parent, false),
instanceName
)
VIEW_TYPE_MODERATION_WARNING -> ModerationWarningViewHolder(
ItemModerationWarningNotificationBinding.inflate(inflater, parent, false),
instanceName
)
else -> UnknownNotificationViewHolder(
ItemUnknownNotificationBinding.inflate(inflater, parent, false)
)
@ -166,7 +184,9 @@ class NotificationsPagingAdapter(
private const val VIEW_TYPE_FOLLOW_REQUEST = 4
private const val VIEW_TYPE_PLACEHOLDER = 5
private const val VIEW_TYPE_REPORT = 6
private const val VIEW_TYPE_UNKNOWN = 7
private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 7
private const val VIEW_TYPE_MODERATION_WARNING = 8
private const val VIEW_TYPE_UNKNOWN = 9
val NotificationsDifferCallback = object : DiffUtil.ItemCallback<NotificationViewData>() {
override fun areItemsTheSame(

View file

@ -21,6 +21,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.components.systemnotifications.toTypes
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
@ -57,7 +58,7 @@ class NotificationsRemoteMediator(
return MediatorResult.Success(endOfPaginationReached = true)
}
val excludes = viewModel.excludes.value
val excludes = viewModel.excludes.value.toTypes()
try {
var dbEmpty = false

View file

@ -34,18 +34,20 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.components.systemnotifications.toTypes
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity
import com.keylesspalace.tusky.entity.Filter
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.viewdata.NotificationViewData
@ -56,6 +58,7 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -84,7 +87,7 @@ class NotificationsViewModel @Inject constructor(
private val refreshTrigger = MutableStateFlow(0L)
val excludes: StateFlow<Set<Notification.Type>> = activeAccountFlow
val excludes: StateFlow<Set<NotificationChannelData>> = activeAccountFlow
.map { account -> account?.notificationsFilter.orEmpty() }
.stateIn(viewModelScope, SharingStarted.Eagerly, activeAccountFlow.value?.notificationsFilter.orEmpty())
@ -119,7 +122,7 @@ class NotificationsViewModel @Inject constructor(
}
.flowOn(Dispatchers.Default)
val notificationPolicy: StateFlow<NotificationPolicyState> = notificationPolicyUsecase.state
val notificationPolicy: Flow<NotificationPolicyEntity?> = notificationPolicyUsecase.info
init {
viewModelScope.launch {
@ -148,7 +151,7 @@ class NotificationsViewModel @Inject constructor(
}
}
fun updateNotificationFilters(newFilters: Set<Notification.Type>) {
fun updateNotificationFilters(newFilters: Set<NotificationChannelData>) {
val account = activeAccountFlow.value
if (newFilters != excludes.value && account != null) {
viewModelScope.launch {
@ -163,7 +166,7 @@ class NotificationsViewModel @Inject constructor(
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action {
return when ((notificationViewData as? NotificationViewData.Concrete)?.type) {
Notification.Type.MENTION, Notification.Type.POLL -> {
Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> {
val account = activeAccountFlow.value
notificationViewData.statusViewData?.let { statusViewData ->
if (statusViewData.status.account.id == account?.accountId) {
@ -320,7 +323,7 @@ class NotificationsViewModel @Inject constructor(
maxId = idAbovePlaceholder,
minId = idBelowPlaceholder,
limit = TimelineViewModel.LOAD_AT_ONCE,
excludes = excludes.value
excludes = excludes.value.toTypes()
)
// Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before
// maxId, and no smaller than minId.
@ -328,7 +331,7 @@ class NotificationsViewModel @Inject constructor(
maxId = idAbovePlaceholder,
sinceId = idBelowPlaceholder,
limit = TimelineViewModel.LOAD_AT_ONCE,
excludes = excludes.value
excludes = excludes.value.toTypes()
)
}

View file

@ -0,0 +1,46 @@
/* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
class SeveredRelationshipNotificationViewHolder(
private val binding: ItemSeveredRelationshipNotificationBinding,
private val instanceName: String
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
val event = viewData.event!!
val context = binding.root.context
binding.severedRelationshipText.text = NotificationService.severedRelationShipText(
context,
event,
instanceName
)
}
}

View file

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
@ -44,8 +45,10 @@ import com.keylesspalace.tusky.util.SmartLengthInputFilter
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.NotificationViewData
@ -84,8 +87,8 @@ internal class StatusNotificationViewHolder(
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
setUsername(account.username)
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
if (viewData.type == Notification.Type.STATUS ||
viewData.type == Notification.Type.UPDATE
if (viewData.type == Notification.Type.Status ||
viewData.type == Notification.Type.Update
) {
setAvatar(
account.avatar,
@ -136,6 +139,7 @@ internal class StatusNotificationViewHolder(
binding.notificationContent.visible(show)
binding.notificationStatusAvatar.visible(show)
binding.notificationNotificationAvatar.visible(show)
binding.notificationAttachmentInfo.visible(show)
}
private fun setDisplayName(name: String, emojis: List<Emoji>, animateEmojis: Boolean) {
@ -230,19 +234,19 @@ internal class StatusNotificationViewHolder(
val format: String
val icon: Drawable?
when (type) {
Notification.Type.FAVOURITE -> {
Notification.Type.Favourite -> {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
format = context.getString(R.string.notification_favourite_format)
}
Notification.Type.REBLOG -> {
Notification.Type.Reblog -> {
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
format = context.getString(R.string.notification_reblog_format)
}
Notification.Type.STATUS -> {
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
Notification.Type.Status -> {
icon = getIconWithColor(context, R.drawable.ic_notifications_active_24dp, R.color.tusky_blue)
format = context.getString(R.string.notification_subscription_format)
}
Notification.Type.UPDATE -> {
Notification.Type.Update -> {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
format = context.getString(R.string.notification_update_format)
}
@ -330,15 +334,18 @@ internal class StatusNotificationViewHolder(
R.string.post_content_warning_show_more
)
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
binding.notificationAttachmentInfo.hide()
} else {
binding.buttonToggleNotificationContent.setText(
R.string.post_content_warning_show_less
)
binding.notificationContent.filters = NO_INPUT_FILTER
setupAttachmentInfo(statusViewData.status)
}
} else {
binding.buttonToggleNotificationContent.visibility = View.GONE
binding.notificationContent.filters = NO_INPUT_FILTER
setupAttachmentInfo(statusViewData.status)
}
val emojifiedText = content.emojify(
emojis = emojis,
@ -360,6 +367,22 @@ internal class StatusNotificationViewHolder(
binding.notificationContentWarningDescription.text = emojifiedContentWarning
}
private fun setupAttachmentInfo(status: Status) {
if (status.attachments.isNotEmpty()) {
binding.notificationAttachmentInfo.show()
binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_attach_file_24dp, 0, 0, 0)
val attachmentCount = status.attachments.size
val attachmentText = binding.root.context.resources.getQuantityString(R.plurals.media_attachments, attachmentCount, attachmentCount)
binding.notificationAttachmentInfo.text = attachmentText
} else if (status.poll != null) {
binding.notificationAttachmentInfo.show()
binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
binding.notificationAttachmentInfo.setText(R.string.poll)
} else {
binding.notificationAttachmentInfo.hide()
}
}
companion object {
private val COLLAPSE_INPUT_FILTER: Array<InputFilter> = arrayOf(SmartLengthInputFilter)
private val NO_INPUT_FILTER: Array<InputFilter> = arrayOf()

View file

@ -18,10 +18,14 @@
package com.keylesspalace.tusky.components.notifications
import android.view.View
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.NotificationViewData
internal class StatusViewHolder(
@ -50,11 +54,40 @@ internal class StatusViewHolder(
payloads,
false
)
}
if (viewData.type == Notification.Type.POLL) {
setPollInfo(accountId == viewData.account.id)
} else {
hideStatusInfo()
if (payloads.isNotEmpty()) {
return
}
if (viewData.type == Notification.Type.Poll) {
statusInfo.setText(if (accountId == viewData.account.id) R.string.poll_ended_created else R.string.poll_ended_voted)
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.context, 10))
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.context, 28), 0, 0, 0)
statusInfo.show()
} else if (viewData.type == Notification.Type.Mention) {
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.context, 6))
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.context, 38), 0, 0, 0)
statusInfo.show()
if (viewData.statusViewData.status.inReplyToAccountId == accountId) {
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_reply_18dp, 0, 0, 0)
if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) {
statusInfo.setText(R.string.notification_info_private_reply)
} else {
statusInfo.setText(R.string.notification_info_reply)
}
} else {
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_at_18dp, 0, 0, 0)
if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) {
statusInfo.setText(R.string.notification_info_private_mention)
} else {
statusInfo.setText(R.string.notification_info_mention)
}
}
} else {
hideStatusInfo()
}
}
}
}

View file

@ -18,12 +18,14 @@
package com.keylesspalace.tusky.components.notifications
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
internal class UnknownNotificationViewHolder(
binding: ItemUnknownNotificationBinding,
private val binding: ItemUnknownNotificationBinding,
) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) {
override fun bind(
@ -31,6 +33,13 @@ internal class UnknownNotificationViewHolder(
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
// nothing to do
binding.unknownNotificationType.text = viewData.type.name
binding.root.setOnClickListener {
MaterialAlertDialogBuilder(binding.root.context)
.setMessage(R.string.unknown_notification_type_explanation)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}

View file

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.NotificationRequest
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import java.text.NumberFormat
class NotificationRequestsAdapter(
private val onAcceptRequest: (notificationRequestId: String) -> Unit,
@ -36,6 +37,8 @@ class NotificationRequestsAdapter(
private val animateEmojis: Boolean,
) : PagingDataAdapter<NotificationRequest, BindingHolder<ItemNotificationRequestBinding>>(NOTIFICATION_REQUEST_COMPARATOR) {
private val numberFormat: NumberFormat = NumberFormat.getNumberInstance()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
@ -58,7 +61,7 @@ class NotificationRequestsAdapter(
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, binding.notificationRequestAvatar, avatarRadius, animateAvatar)
binding.notificationRequestBadge.text = notificationRequest.notificationsCount
binding.notificationRequestBadge.text = numberFormat.format(notificationRequest.notificationsCount)
val emojifiedName = account.name.emojify(
account.emojis,

View file

@ -28,6 +28,7 @@ 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 com.keylesspalace.tusky.usecase.NotificationPolicyUsecase
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
@ -39,7 +40,8 @@ import kotlinx.coroutines.launch
@HiltViewModel
class NotificationRequestsViewModel @Inject constructor(
private val api: MastodonApi,
private val eventHub: EventHub
private val eventHub: EventHub,
private val notificationPolicyUsecase: NotificationPolicyUsecase
) : ViewModel() {
var currentSource: NotificationRequestsPagingSource? = null
@ -108,6 +110,13 @@ class NotificationRequestsViewModel @Inject constructor(
}
fun removeNotificationRequest(id: String) {
requestData.forEach { request ->
if (request.id == id) {
viewModelScope.launch {
notificationPolicyUsecase.updateCounts(request.notificationsCount)
}
}
}
requestData.removeAll { request -> request.id == id }
currentSource?.invalidate()
}

View file

@ -102,9 +102,10 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
}
private fun setupAdapter(): NotificationsPagingAdapter {
val activeAccount = accountManager.activeAccount!!
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
mediaPreviewEnabled = 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),
@ -118,16 +119,17 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat
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
showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia,
openSpoiler = activeAccount.alwaysOpenSpoiler
)
return NotificationsPagingAdapter(
accountId = accountManager.activeAccount!!.accountId,
accountId = activeAccount.accountId,
statusDisplayOptions = statusDisplayOptions,
statusListener = this,
notificationActionListener = this,
accountActionListener = this
accountActionListener = this,
instanceName = activeAccount.domain
).apply {
addLoadStateListener { loadState ->
binding.progressBar.visible(

View file

@ -62,8 +62,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
category.isIconSpaceReserved = false
switchPreference {
setTitle(R.string.pref_title_notification_filter_follows)
key = PrefKeys.NOTIFICATIONS_FILTER_FOLLOWS
setTitle(R.string.notification_follow_name)
setSummary(R.string.notification_follow_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsFollowed
setOnPreferenceChangeListener { _, newValue ->
@ -73,8 +73,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_follow_requests)
key = PrefKeys.NOTIFICATION_FILTER_FOLLOW_REQUESTS
setTitle(R.string.notification_follow_request_name)
setSummary(R.string.notification_follow_request_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsFollowRequested
setOnPreferenceChangeListener { _, newValue ->
@ -84,8 +84,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_reblogs)
key = PrefKeys.NOTIFICATION_FILTER_REBLOGS
setTitle(R.string.notification_boost_name)
setSummary(R.string.notification_boost_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsReblogged
setOnPreferenceChangeListener { _, newValue ->
@ -95,8 +95,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_favourites)
key = PrefKeys.NOTIFICATION_FILTER_FAVS
setTitle(R.string.notification_favourite_name)
setSummary(R.string.notification_favourite_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsFavorited
setOnPreferenceChangeListener { _, newValue ->
@ -106,8 +106,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_poll)
key = PrefKeys.NOTIFICATION_FILTER_POLLS
setTitle(R.string.notification_poll_name)
setSummary(R.string.notification_poll_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsPolls
setOnPreferenceChangeListener { _, newValue ->
@ -117,8 +117,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_subscriptions)
key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS
setTitle(R.string.notification_subscription_name)
setSummary(R.string.notification_subscription_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSubscriptions
setOnPreferenceChangeListener { _, newValue ->
@ -128,19 +128,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_sign_ups)
key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSignUps
setOnPreferenceChangeListener { _, newValue ->
updateAccount { copy(notificationsSignUps = newValue as Boolean) }
true
}
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_updates)
key = PrefKeys.NOTIFICATION_FILTER_UPDATES
setTitle(R.string.notification_update_name)
setSummary(R.string.notification_update_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsUpdates
setOnPreferenceChangeListener { _, newValue ->
@ -150,12 +139,23 @@ class NotificationPreferencesFragment : BasePreferencesFragment() {
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_reports)
key = PrefKeys.NOTIFICATION_FILTER_REPORTS
setTitle(R.string.notification_channel_admin)
setSummary(R.string.notification_channel_admin_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsReports
isChecked = activeAccount.notificationsAdmin
setOnPreferenceChangeListener { _, newValue ->
updateAccount { copy(notificationsReports = newValue as Boolean) }
updateAccount { copy(notificationsAdmin = newValue as Boolean) }
true
}
}
switchPreference {
setTitle(R.string.notification_channel_other)
setSummary(R.string.notification_channel_other_description)
isIconSpaceReserved = false
isChecked = activeAccount.notificationsOther
setOnPreferenceChangeListener { _, newValue ->
updateAccount { copy(notificationsOther = newValue as Boolean) }
true
}
}

View file

@ -19,8 +19,8 @@ import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.emojiPreference
@ -245,13 +245,13 @@ class PreferencesFragment : BasePreferencesFragment() {
val notificationFilter = account.notificationsFilter.toMutableSet()
if (value == true) {
notificationFilter.add(Notification.Type.FAVOURITE)
notificationFilter.add(Notification.Type.FOLLOW)
notificationFilter.add(Notification.Type.REBLOG)
notificationFilter.add(NotificationChannelData.FAVOURITE)
notificationFilter.add(NotificationChannelData.FOLLOW)
notificationFilter.add(NotificationChannelData.REBLOG)
} else {
notificationFilter.remove(Notification.Type.FAVOURITE)
notificationFilter.remove(Notification.Type.FOLLOW)
notificationFilter.remove(Notification.Type.REBLOG)
notificationFilter.remove(NotificationChannelData.FAVOURITE)
notificationFilter.remove(NotificationChannelData.FOLLOW)
notificationFilter.remove(NotificationChannelData.REBLOG)
}
lifecycleScope.launch {

View file

@ -0,0 +1,86 @@
package com.keylesspalace.tusky.components.systemnotifications
import androidx.annotation.Keep
import androidx.annotation.StringRes
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Notification
@Keep
enum class NotificationChannelData(
val notificationTypes: List<Notification.Type>,
@StringRes val title: Int,
@StringRes val description: Int,
) {
MENTION(
listOf(Notification.Type.Mention),
R.string.notification_mention_name,
R.string.notification_mention_descriptions,
),
REBLOG(
listOf(Notification.Type.Reblog),
R.string.notification_boost_name,
R.string.notification_boost_description
),
FAVOURITE(
listOf(Notification.Type.Favourite),
R.string.notification_favourite_name,
R.string.notification_favourite_description
),
FOLLOW(
listOf(Notification.Type.Follow),
R.string.notification_follow_name,
R.string.notification_follow_description
),
FOLLOW_REQUEST(
listOf(Notification.Type.FollowRequest),
R.string.notification_follow_request_name,
R.string.notification_follow_request_description
),
POLL(
listOf(Notification.Type.Poll),
R.string.notification_poll_name,
R.string.notification_poll_description
),
STATUS(
listOf(Notification.Type.Status),
R.string.notification_subscription_name,
R.string.notification_subscription_description
),
UPDATE(
listOf(Notification.Type.Update),
R.string.notification_update_name,
R.string.notification_update_description
),
ADMIN(
listOf(Notification.Type.SignUp, Notification.Type.Report),
R.string.notification_channel_admin,
R.string.notification_channel_admin_description
),
OTHER(
listOf(Notification.Type.SeveredRelationship, Notification.Type.ModerationWarning),
R.string.notification_channel_other,
R.string.notification_channel_other_description
);
fun getChannelId(account: AccountEntity): String {
return getChannelId(account.identifier)
}
fun getChannelId(accountIdentifier: String): String {
return "CHANNEL_${name}_$accountIdentifier"
}
}
fun Set<NotificationChannelData>.toTypes(): Set<Notification.Type> {
return flatMap { channelData -> channelData.notificationTypes }.toSet()
}

View file

@ -14,15 +14,16 @@ import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.service.notification.StatusBarNotification
import android.text.TextUtils
import android.util.Log
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
import androidx.core.app.RemoteInput
import androidx.core.app.TaskStackBuilder
import androidx.core.net.toUri
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
@ -48,6 +49,8 @@ import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent
import com.keylesspalace.tusky.entity.visibleNotificationTypes
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
import com.keylesspalace.tusky.settings.PrefKeys
@ -58,6 +61,7 @@ import com.keylesspalace.tusky.viewdata.buildDescription
import com.keylesspalace.tusky.viewdata.calculatePercent
import com.keylesspalace.tusky.worker.NotificationWorker
import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.NumberFormat
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -138,71 +142,15 @@ class NotificationService @Inject constructor(
fun createNotificationChannelsForAccount(account: AccountEntity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
data class ChannelData(
val id: String,
@StringRes val name: Int,
@StringRes val description: Int,
)
val channelData = arrayOf(
ChannelData(
getChannelId(account, Notification.Type.MENTION)!!,
R.string.notification_mention_name,
R.string.notification_mention_descriptions,
),
ChannelData(
getChannelId(account, Notification.Type.FOLLOW)!!,
R.string.notification_follow_name,
R.string.notification_follow_description,
),
ChannelData(
getChannelId(account, Notification.Type.FOLLOW_REQUEST)!!,
R.string.notification_follow_request_name,
R.string.notification_follow_request_description,
),
ChannelData(
getChannelId(account, Notification.Type.REBLOG)!!,
R.string.notification_boost_name,
R.string.notification_boost_description,
),
ChannelData(
getChannelId(account, Notification.Type.FAVOURITE)!!,
R.string.notification_favourite_name,
R.string.notification_favourite_description,
),
ChannelData(
getChannelId(account, Notification.Type.POLL)!!,
R.string.notification_poll_name,
R.string.notification_poll_description,
),
ChannelData(
getChannelId(account, Notification.Type.STATUS)!!,
R.string.notification_subscription_name,
R.string.notification_subscription_description,
),
ChannelData(
getChannelId(account, Notification.Type.SIGN_UP)!!,
R.string.notification_sign_up_name,
R.string.notification_sign_up_description,
),
ChannelData(
getChannelId(account, Notification.Type.UPDATE)!!,
R.string.notification_update_name,
R.string.notification_update_description,
),
ChannelData(
getChannelId(account, Notification.Type.REPORT)!!,
R.string.notification_report_name,
R.string.notification_report_description,
),
)
// TODO enumerate all keys of Notification.Type and check if one is missing here?
val channelGroup = NotificationChannelGroup(account.identifier, account.fullName)
notificationManager.createNotificationChannelGroup(channelGroup)
val channels = channelData.map {
NotificationChannel(it.id, context.getString(it.name), NotificationManager.IMPORTANCE_DEFAULT).apply {
val channels = NotificationChannelData.entries.map {
NotificationChannel(
it.getChannelId(account),
context.getString(it.title),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = context.getString(it.description)
enableLights(true)
lightColor = -0xd46f27
@ -260,17 +208,17 @@ class NotificationService @Inject constructor(
}
return when (type) {
Notification.Type.MENTION -> account.notificationsMentioned
Notification.Type.STATUS -> account.notificationsSubscriptions
Notification.Type.FOLLOW -> account.notificationsFollowed
Notification.Type.FOLLOW_REQUEST -> account.notificationsFollowRequested
Notification.Type.REBLOG -> account.notificationsReblogged
Notification.Type.FAVOURITE -> account.notificationsFavorited
Notification.Type.POLL -> account.notificationsPolls
Notification.Type.SIGN_UP -> account.notificationsSignUps
Notification.Type.UPDATE -> account.notificationsUpdates
Notification.Type.REPORT -> account.notificationsReports
else -> false
Notification.Type.Mention -> account.notificationsMentioned
Notification.Type.Status -> account.notificationsSubscriptions
Notification.Type.Follow -> account.notificationsFollowed
Notification.Type.FollowRequest -> account.notificationsFollowRequested
Notification.Type.Reblog -> account.notificationsReblogged
Notification.Type.Favourite -> account.notificationsFavorited
Notification.Type.Poll -> account.notificationsPolls
Notification.Type.SignUp -> account.notificationsAdmin
Notification.Type.Update -> account.notificationsUpdates
Notification.Type.Report -> account.notificationsAdmin
else -> account.notificationsOther
}
}
@ -315,7 +263,7 @@ class NotificationService @Inject constructor(
)
}
// Only public for one test...
@VisibleForTesting
fun createBaseNotification(apiNotification: Notification, account: AccountEntity): android.app.Notification? {
val channelId = getChannelId(account, apiNotification.type) ?: return null
@ -333,41 +281,43 @@ class NotificationService @Inject constructor(
notificationId++
val builder = if (existingAndroidNotification == null) {
getNotificationBuilder(body.type, account, channelId)
getNotificationBuilder(body, account, channelId)
} else {
NotificationCompat.Builder(context, existingAndroidNotification)
}
builder
.setContentTitle(titleForType(body, account))
.setContentText(bodyForType(body, account.alwaysOpenSpoiler))
.setContentText(bodyForType(body, account))
if (body.type == Notification.Type.MENTION || body.type == Notification.Type.POLL) {
if (body.type == Notification.Type.Mention || body.type == Notification.Type.Poll) {
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(bodyForType(body, account.alwaysOpenSpoiler))
.bigText(bodyForType(body, account))
)
}
val accountAvatar = try {
Glide.with(context)
.asBitmap()
.load(body.account.avatar)
.transform(RoundedCorners(20))
.submit()
.get()
} catch (e: ExecutionException) {
Log.d(TAG, "Error loading account avatar", e)
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
} catch (e: InterruptedException) {
Log.d(TAG, "Error loading account avatar", e)
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
if (body.type != Notification.Type.SeveredRelationship && body.type != Notification.Type.ModerationWarning) {
val accountAvatar = try {
Glide.with(context)
.asBitmap()
.load(body.account.avatar)
.transform(RoundedCorners(20))
.submit()
.get()
} catch (e: ExecutionException) {
Log.d(TAG, "Error loading account avatar", e)
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
} catch (e: InterruptedException) {
Log.d(TAG, "Error loading account avatar", e)
BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default)
}
builder.setLargeIcon(accountAvatar)
}
builder.setLargeIcon(accountAvatar)
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.type == Notification.Type.MENTION) {
if (body.type == Notification.Type.Mention) {
val replyRemoteInput = RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build()
@ -471,19 +421,9 @@ class NotificationService @Inject constructor(
}
private fun getChannelId(account: AccountEntity, type: Notification.Type): String? {
return when (type) {
Notification.Type.MENTION -> CHANNEL_MENTION + account.identifier
Notification.Type.STATUS -> "CHANNEL_SUBSCRIPTIONS" + account.identifier
Notification.Type.FOLLOW -> "CHANNEL_FOLLOW" + account.identifier
Notification.Type.FOLLOW_REQUEST -> "CHANNEL_FOLLOW_REQUEST" + account.identifier
Notification.Type.REBLOG -> "CHANNEL_BOOST" + account.identifier
Notification.Type.FAVOURITE -> "CHANNEL_FAVOURITE" + account.identifier
Notification.Type.POLL -> "CHANNEL_POLL" + account.identifier
Notification.Type.SIGN_UP -> "CHANNEL_SIGN_UP" + account.identifier
Notification.Type.UPDATE -> "CHANNEL_UPDATES" + account.identifier
Notification.Type.REPORT -> "CHANNEL_REPORT" + account.identifier
else -> null
}
return NotificationChannelData.entries.find { data ->
data.notificationTypes.contains(type)
}?.getChannelId(account)
}
/**
@ -499,17 +439,24 @@ class NotificationService @Inject constructor(
}
}
private fun getNotificationBuilder(notificationType: Notification.Type, account: AccountEntity, channelId: String): NotificationCompat.Builder {
val eventResultIntent = openNotificationIntent(context, account.id, notificationType)
private fun getNotificationBuilder(notification: Notification, account: AccountEntity, channelId: String): NotificationCompat.Builder {
val notificationType = notification.type
val eventResultPendingIntent = if (notificationType == Notification.Type.ModerationWarning) {
val warning = notification.moderationWarning!!
val intent = Intent(Intent.ACTION_VIEW, "https://${account.domain}/disputes/strikes/${warning.id}".toUri())
PendingIntent.getActivity(context, account.id.toInt(), intent, pendingIntentFlags(false))
} else {
val eventResultIntent = openNotificationIntent(context, account.id, notificationType)
val eventStackBuilder = TaskStackBuilder.create(context)
eventStackBuilder.addParentStack(MainActivity::class.java)
eventStackBuilder.addNextIntent(eventResultIntent)
val eventStackBuilder = TaskStackBuilder.create(context)
eventStackBuilder.addParentStack(MainActivity::class.java)
eventStackBuilder.addNextIntent(eventResultIntent)
val eventResultPendingIntent = eventStackBuilder.getPendingIntent(
account.id.toInt(),
pendingIntentFlags(false)
)
eventStackBuilder.getPendingIntent(
account.id.toInt(),
pendingIntentFlags(false)
)
}
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notify)
@ -530,46 +477,42 @@ class NotificationService @Inject constructor(
}
private fun titleForType(notification: Notification, account: AccountEntity): String? {
if (notification.status == null) {
return null
}
val accountName = notification.account.name.unicodeWrap()
when (notification.type) {
Notification.Type.MENTION -> return context.getString(R.string.notification_mention_format, accountName)
Notification.Type.STATUS -> return context.getString(R.string.notification_subscription_format, accountName)
Notification.Type.FOLLOW -> return context.getString(R.string.notification_follow_format, accountName)
Notification.Type.FOLLOW_REQUEST -> return context.getString(R.string.notification_follow_request_format, accountName)
Notification.Type.FAVOURITE -> return context.getString(R.string.notification_favourite_format, accountName)
Notification.Type.REBLOG -> return context.getString(R.string.notification_reblog_format, accountName)
Notification.Type.POLL -> return if (notification.status.account.id == account.accountId) {
Notification.Type.Mention -> return context.getString(R.string.notification_mention_format, accountName)
Notification.Type.Status -> return context.getString(R.string.notification_subscription_format, accountName)
Notification.Type.Follow -> return context.getString(R.string.notification_follow_format, accountName)
Notification.Type.FollowRequest -> return context.getString(R.string.notification_follow_request_format, accountName)
Notification.Type.Favourite -> return context.getString(R.string.notification_favourite_format, accountName)
Notification.Type.Reblog -> return context.getString(R.string.notification_reblog_format, accountName)
Notification.Type.Poll -> return if (notification.status!!.account.id == account.accountId) {
context.getString(R.string.poll_ended_created)
} else {
context.getString(R.string.poll_ended_voted)
}
Notification.Type.SIGN_UP -> return context.getString(R.string.notification_sign_up_format, accountName)
Notification.Type.UPDATE -> return context.getString(R.string.notification_update_format, accountName)
Notification.Type.REPORT -> return context.getString(R.string.notification_report_format, account.domain)
Notification.Type.UNKNOWN -> return null
Notification.Type.SignUp -> return context.getString(R.string.notification_sign_up_format, accountName)
Notification.Type.Update -> return context.getString(R.string.notification_update_format, accountName)
Notification.Type.Report -> return context.getString(R.string.notification_report_format, account.domain)
Notification.Type.SeveredRelationship -> return context.getString(R.string.relationship_severance_event_title)
Notification.Type.ModerationWarning -> return context.getString(R.string.moderation_warning)
is Notification.Type.Unknown -> return null
}
}
private fun bodyForType(notification: Notification, alwaysOpenSpoiler: Boolean): String? {
if (notification.status == null) {
return null
}
private fun bodyForType(notification: Notification, account: AccountEntity): String? {
val alwaysOpenSpoiler = account.alwaysOpenSpoiler
when (notification.type) {
Notification.Type.FOLLOW, Notification.Type.FOLLOW_REQUEST, Notification.Type.SIGN_UP -> return "@" + notification.account.username
Notification.Type.MENTION, Notification.Type.FAVOURITE, Notification.Type.REBLOG, Notification.Type.STATUS -> return if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) {
Notification.Type.Follow, Notification.Type.FollowRequest, Notification.Type.SignUp -> return "@" + notification.account.username
Notification.Type.Mention, Notification.Type.Favourite, Notification.Type.Reblog, Notification.Type.Status -> return if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) {
notification.status.spoilerText
} else {
notification.status.content.parseAsMastodonHtml().toString()
notification.status?.content?.parseAsMastodonHtml()?.toString()
}
Notification.Type.POLL -> if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) {
Notification.Type.Poll -> if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) {
return notification.status.spoilerText
} else {
val poll = notification.status.poll ?: return null
val poll = notification.status?.poll ?: return null
val builder = StringBuilder(notification.status.content.parseAsMastodonHtml())
builder.append('\n')
@ -588,11 +531,13 @@ class NotificationService @Inject constructor(
return builder.toString()
}
Notification.Type.REPORT -> return context.getString(
Notification.Type.Report -> return context.getString(
R.string.notification_header_report_format,
notification.account.name.unicodeWrap(),
notification.report!!.targetAccount.name.unicodeWrap()
)
Notification.Type.SeveredRelationship -> return severedRelationShipText(context, notification.event!!, account.domain)
Notification.Type.ModerationWarning -> return context.getString(notification.moderationWarning!!.action.text)
else -> return null
}
}
@ -908,8 +853,8 @@ class NotificationService @Inject constructor(
private fun buildAlertsMap(account: AccountEntity): Map<String, Boolean> =
buildMap {
Notification.Type.visibleTypes.forEach {
put(it.presentation, filterNotification(account, it))
visibleNotificationTypes.forEach {
put(it.name, filterNotification(account, it))
}
}
@ -1010,7 +955,6 @@ class NotificationService @Inject constructor(
companion object {
const val TAG = "NotificationService"
const val CHANNEL_MENTION: String = "CHANNEL_MENTION"
const val KEY_CITED_STATUS_ID: String = "KEY_CITED_STATUS_ID"
const val KEY_MENTIONS: String = "KEY_MENTIONS"
const val KEY_REPLY: String = "KEY_REPLY"
@ -1029,5 +973,33 @@ class NotificationService @Inject constructor(
private const val EXTRA_NOTIFICATION_TYPE = BuildConfig.APPLICATION_ID + ".notification.extra.notification_type"
private const val GROUP_SUMMARY_TAG = BuildConfig.APPLICATION_ID + ".notification.group_summary"
private const val NOTIFICATION_PULL_NAME = "pullNotifications"
private val numberFormat = NumberFormat.getNumberInstance()
fun severedRelationShipText(
context: Context,
event: RelationshipSeveranceEvent,
instanceName: String
): String {
return when (event.type) {
RelationshipSeveranceEvent.Type.DOMAIN_BLOCK -> {
val followers = numberFormat.format(event.followersCount)
val following = numberFormat.format(event.followingCount)
val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following)
context.getString(R.string.relationship_severance_event_domain_block, instanceName, event.targetName, followers, followingText)
}
RelationshipSeveranceEvent.Type.USER_DOMAIN_BLOCK -> {
val followers = numberFormat.format(event.followersCount)
val following = numberFormat.format(event.followingCount)
val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following)
context.getString(R.string.relationship_severance_event_user_domain_block, event.targetName, followers, followingText)
}
RelationshipSeveranceEvent.Type.ACCOUNT_SUSPENSION -> {
context.getString(R.string.relationship_severance_event_account_suspension, instanceName, event.targetName)
}
}
}
}
}

View file

@ -40,6 +40,7 @@ fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity {
url = url,
avatar = avatar,
emojis = emojis,
note = note,
bot = bot
)
}
@ -50,7 +51,7 @@ fun TimelineAccountEntity.toAccount(): TimelineAccount {
localUsername = localUsername,
username = username,
displayName = displayName,
note = "",
note = note,
url = url,
avatar = avatar,
bot = bot,

View file

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationEntity;
import com.keylesspalace.tusky.db.dao.AccountDao;
import com.keylesspalace.tusky.db.dao.DraftDao;
import com.keylesspalace.tusky.db.dao.InstanceDao;
import com.keylesspalace.tusky.db.dao.NotificationPolicyDao;
import com.keylesspalace.tusky.db.dao.NotificationsDao;
import com.keylesspalace.tusky.db.dao.TimelineAccountDao;
import com.keylesspalace.tusky.db.dao.TimelineDao;
@ -39,6 +40,7 @@ import com.keylesspalace.tusky.db.entity.DraftEntity;
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity;
import com.keylesspalace.tusky.db.entity.InstanceEntity;
import com.keylesspalace.tusky.db.entity.NotificationEntity;
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity;
import com.keylesspalace.tusky.db.entity.NotificationReportEntity;
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity;
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity;
@ -58,11 +60,12 @@ import java.io.File;
ConversationEntity.class,
NotificationEntity.class,
NotificationReportEntity.class,
HomeTimelineEntity.class
HomeTimelineEntity.class,
NotificationPolicyEntity.class
},
// Note: Starting with version 54, database versions in Tusky are always even.
// This is to reserve odd version numbers for use by forks.
version = 66,
version = 68,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@ -72,6 +75,7 @@ import java.io.File;
@AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity
@AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity
@AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity
@AutoMigration(from = 66, to = 68, spec = AppDatabase.MIGRATION_66_68.class), // added event and moderationAction to NotificationEntity, new NotificationPolicyEntity
}
)
public abstract class AppDatabase extends RoomDatabase {
@ -84,6 +88,7 @@ public abstract class AppDatabase extends RoomDatabase {
@NonNull public abstract NotificationsDao notificationsDao();
@NonNull public abstract TimelineStatusDao timelineStatusDao();
@NonNull public abstract TimelineAccountDao timelineAccountDao();
@NonNull public abstract NotificationPolicyDao notificationPolicyDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -850,4 +855,8 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultReplyPrivacy` INTEGER NOT NULL DEFAULT 0");
}
};
@DeleteColumn(tableName = "AccountEntity", columnName = "notificationsSignUps")
@DeleteColumn(tableName = "AccountEntity", columnName = "notificationsReports")
static class MIGRATION_66_68 implements AutoMigrationSpec { }
}

View file

@ -19,8 +19,10 @@ import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.entity.AccountWarning
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.FilterResult
@ -29,7 +31,9 @@ import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PreviewCard
import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.notificationTypeFromString
import com.keylesspalace.tusky.settings.DefaultReplyVisibility
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
@ -229,27 +233,59 @@ class Converters @Inject constructor(
}
@TypeConverter
fun notificationTypeListToJson(data: Set<Notification.Type>?): String {
fun notificationChannelDataListToJson(data: Set<NotificationChannelData>?): String {
val array = JSONArray()
data?.forEach {
array.put(it.presentation)
array.put(it.name)
}
return array.toString()
}
@TypeConverter
fun jsonToNotificationTypeList(data: String?): Set<Notification.Type> {
val ret = HashSet<Notification.Type>()
fun jsonToNotificationChannelDataList(data: String?): Set<NotificationChannelData> {
val ret = HashSet<NotificationChannelData>()
data?.let {
val array = JSONArray(data)
for (i in 0 until array.length()) {
val item = array.getString(i)
val type = Notification.Type.byString(item)
if (type != Notification.Type.UNKNOWN) {
try {
val type = NotificationChannelData.valueOf(item)
ret.add(type)
} catch (_: IllegalArgumentException) {
// ignore, this can happen because we stored individual notification types and not channels before
}
}
}
return ret
}
@TypeConverter
fun relationshipSeveranceEventToJson(event: RelationshipSeveranceEvent?): String {
return moshi.adapter<RelationshipSeveranceEvent?>().toJson(event)
}
@TypeConverter
fun jsonToRelationshipSeveranceEvent(eventJson: String?): RelationshipSeveranceEvent? {
return eventJson?.let { moshi.adapter<RelationshipSeveranceEvent?>().fromJson(it) }
}
@TypeConverter
fun accountWarningToJson(accountWarning: AccountWarning?): String {
return moshi.adapter<AccountWarning?>().toJson(accountWarning)
}
@TypeConverter
fun jsonToAccountWarning(accountWarningJson: String?): AccountWarning? {
return accountWarningJson?.let { moshi.adapter<AccountWarning?>().fromJson(it) }
}
@TypeConverter
fun accountWarningToJson(notificationType: Notification.Type): String {
return notificationType.name
}
@TypeConverter
fun jsonToNotificationType(notificationTypeJson: String): Notification.Type {
return notificationTypeFromString(notificationTypeJson)
}
}

View file

@ -0,0 +1,43 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface NotificationPolicyDao {
@Query("SELECT * FROM NotificationPolicyEntity WHERE tuskyAccountId = :accountId")
fun notificationPolicyForAccount(accountId: Long): Flow<NotificationPolicyEntity?>
@Insert(onConflict = REPLACE)
suspend fun update(entity: NotificationPolicyEntity)
@Query(
"UPDATE NotificationPolicyEntity " +
"SET pendingRequestsCount = max(0, pendingRequestsCount - 1)," +
"pendingNotificationsCount = max(0, pendingNotificationsCount - :notificationCount) " +
"WHERE tuskyAccountId = :accountId"
)
suspend fun updateCounts(
accountId: Long,
notificationCount: Int
)
}

View file

@ -35,11 +35,11 @@ abstract class NotificationsDao {
@Query(
"""
SELECT n.tuskyAccountId, n.type, n.id, n.loading,
SELECT n.tuskyAccountId, n.type, n.id, n.loading, n.event, n.moderationWarning,
a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
a.emojis as 'a_emojis', a.bot as 'a_bot',
a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot',
s.serverId as 's_serverId', s.url as 's_url', s.tuskyAccountId as 's_tuskyAccountId',
s.authorServerId as 's_authorServerId', s.inReplyToId as 's_inReplyToId', s.inReplyToAccountId as 's_inReplyToAccountId',
s.content as 's_content', s.createdAt as 's_createdAt', s.editedAt as 's_editedAt', s.emojis as 's_emojis', s.reblogsCount as 's_reblogsCount',
@ -51,14 +51,14 @@ s.pinned as 's_pinned', s.language as 's_language', s.filtered as 's_filtered',
sa.serverId as 'sa_serverId', sa.tuskyAccountId as 'sa_tuskyAccountId',
sa.localUsername as 'sa_localUsername', sa.username as 'sa_username',
sa.displayName as 'sa_displayName', sa.url as 'sa_url', sa.avatar as 'sa_avatar',
sa.emojis as 'sa_emojis', sa.bot as 'sa_bot',
sa.note as 'sa_note', sa.emojis as 'sa_emojis', sa.bot as 'sa_bot',
r.serverId as 'r_serverId', r.tuskyAccountId as 'r_tuskyAccountId',
r.category as 'r_category', r.statusIds as 'r_statusIds',
r.createdAt as 'r_createdAt', r.targetAccountId as 'r_targetAccountId',
ra.serverId as 'ra_serverId', ra.tuskyAccountId as 'ra_tuskyAccountId',
ra.localUsername as 'ra_localUsername', ra.username as 'ra_username',
ra.displayName as 'ra_displayName', ra.url as 'ra_url', ra.avatar as 'ra_avatar',
ra.emojis as 'ra_emojis', ra.bot as 'ra_bot'
ra.note as 'ra_note', ra.emojis as 'ra_emojis', ra.bot as 'ra_bot'
FROM NotificationEntity n
LEFT JOIN TimelineAccountEntity a ON (n.tuskyAccountId = a.tuskyAccountId AND n.accountId = a.serverId)
LEFT JOIN TimelineStatusEntity s ON (n.tuskyAccountId = s.tuskyAccountId AND n.statusId = s.serverId)
@ -111,7 +111,7 @@ AND
(accountId = :userId OR
statusId IN (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId = :userId)
)
AND type != "SIGN_UP" AND type != "REPORT"
AND type != "admin.sign_up" AND type != "admin.report"
"""
)
abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String)

View file

@ -39,15 +39,15 @@ s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing,
a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
a.emojis as 'a_emojis', a.bot as 'a_bot',
a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot',
rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId',
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
rb.note as 'rb_note', rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
replied.serverId as 'replied_serverId', replied.tuskyAccountId 'replied_tuskyAccountId',
replied.localUsername as 'replied_localUsername', replied.username as 'replied_username',
replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar',
replied.emojis as 'replied_emojis', replied.bot as 'replied_bot',
replied.note as 'replied_note', replied.emojis as 'replied_emojis', replied.bot as 'replied_bot',
h.loading
FROM HomeTimelineEntity h
LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId)

View file

@ -21,10 +21,10 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.defaultTabs
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.DefaultReplyVisibility
@ -54,14 +54,14 @@ data class AccountEntity(
val notificationsEnabled: Boolean = true,
val notificationsMentioned: Boolean = true,
val notificationsFollowed: Boolean = true,
val notificationsFollowRequested: Boolean = false,
val notificationsFollowRequested: Boolean = true,
val notificationsReblogged: Boolean = true,
val notificationsFavorited: Boolean = true,
val notificationsPolls: Boolean = true,
val notificationsSubscriptions: Boolean = true,
val notificationsSignUps: Boolean = true,
val notificationsUpdates: Boolean = true,
val notificationsReports: Boolean = true,
@ColumnInfo(defaultValue = "true") val notificationsAdmin: Boolean = true,
@ColumnInfo(defaultValue = "true") val notificationsOther: Boolean = true,
val notificationSound: Boolean = true,
val notificationVibration: Boolean = true,
val notificationLight: Boolean = true,
@ -94,7 +94,7 @@ data class AccountEntity(
val notificationMarkerId: String = "0",
val emojis: List<Emoji> = emptyList(),
val tabPreferences: List<TabData> = defaultTabs(),
val notificationsFilter: Set<Notification.Type> = setOf(Notification.Type.FOLLOW_REQUEST),
val notificationsFilter: Set<NotificationChannelData> = emptySet(),
// Scope cannot be changed without re-login, so store it in case
// the scope needs to be changed in the future
val oauthScopes: String = "",

View file

@ -21,9 +21,12 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.AccountWarning
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent
import java.util.Date
@TypeConverters(Converters::class)
data class NotificationDataEntity(
// id of the account logged into Tusky this notifications belongs to
val tuskyAccountId: Long,
@ -35,6 +38,8 @@ data class NotificationDataEntity(
@Embedded(prefix = "sa_") val statusAccount: TimelineAccountEntity?,
@Embedded(prefix = "r_") val report: NotificationReportEntity?,
@Embedded(prefix = "ra_") val reportTargetAccount: TimelineAccountEntity?,
val event: RelationshipSeveranceEvent?,
val moderationWarning: AccountWarning?,
// relevant when it is a placeholder
val loading: Boolean = false
)
@ -76,6 +81,8 @@ data class NotificationEntity(
val accountId: String?,
val statusId: String?,
val reportId: String?,
val event: RelationshipSeveranceEvent?,
val moderationWarning: AccountWarning?,
// relevant when it is a placeholder
val loading: Boolean = false
)

View file

@ -0,0 +1,11 @@
package com.keylesspalace.tusky.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class NotificationPolicyEntity(
@PrimaryKey val tuskyAccountId: Long,
val pendingRequestsCount: Int,
val pendingNotificationsCount: Int
)

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters
@ -32,6 +33,7 @@ data class TimelineAccountEntity(
val displayName: String,
val url: String,
val avatar: String,
@ColumnInfo(defaultValue = "") val note: String,
val emojis: List<Emoji>,
val bot: Boolean
)

View file

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.GuardedAdapter
import com.keylesspalace.tusky.json.NotificationTypeAdapter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.apiForAccount
@ -91,8 +92,7 @@ object NetworkModule {
)
.add(
Notification.Type::class.java,
EnumJsonAdapter.create(Notification.Type::class.java)
.withUnknownFallback(Notification.Type.UNKNOWN)
NotificationTypeAdapter()
)
.add(
Status.Visibility::class.java,

View file

@ -0,0 +1,52 @@
/* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import androidx.annotation.StringRes
import com.keylesspalace.tusky.R
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AccountWarning(
val id: String,
val action: Action
) {
@JsonClass(generateAdapter = false)
enum class Action(@StringRes val text: Int) {
@Json(name = "none")
NONE(R.string.moderation_warning_action_none),
@Json(name = "disable")
DISABLE(R.string.moderation_warning_action_disable),
@Json(name = "mark_statuses_as_sensitive")
MARK_STATUSES_AS_SENSITIVE(R.string.moderation_warning_action_mark_statuses_as_sensitive),
@Json(name = "delete_statuses")
DELETE_STATUSES(R.string.moderation_warning_action_delete_statuses),
@Json(name = "sensitive")
SENSITIVE(R.string.moderation_warning_action_sensitive),
@Json(name = "silence")
SILENCE(R.string.moderation_warning_action_silence),
@Json(name = "suspend")
SUSPEND(R.string.moderation_warning_action_suspend),
}
}

View file

@ -15,8 +15,17 @@
package com.keylesspalace.tusky.entity
import androidx.annotation.StringRes
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Notification.Type
import com.keylesspalace.tusky.entity.Notification.Type.Favourite
import com.keylesspalace.tusky.entity.Notification.Type.Follow
import com.keylesspalace.tusky.entity.Notification.Type.FollowRequest
import com.keylesspalace.tusky.entity.Notification.Type.Mention
import com.keylesspalace.tusky.entity.Notification.Type.ModerationWarning
import com.keylesspalace.tusky.entity.Notification.Type.Reblog
import com.keylesspalace.tusky.entity.Notification.Type.SeveredRelationship
import com.keylesspalace.tusky.entity.Notification.Type.SignUp
import com.keylesspalace.tusky.entity.Notification.Type.Unknown
import com.keylesspalace.tusky.entity.Notification.Type.Update
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@ -28,78 +37,77 @@ data class Notification(
val status: Status? = null,
val report: Report? = null,
val filtered: Boolean = false,
val event: RelationshipSeveranceEvent? = null,
@Json(name = "moderation_warning") val moderationWarning: AccountWarning? = null
) {
/** From https://docs.joinmastodon.org/entities/Notification/#type */
@JsonClass(generateAdapter = false)
enum class Type(val presentation: String, @StringRes val uiString: Int) {
UNKNOWN("unknown", R.string.notification_unknown_name),
sealed class Type(val name: String) {
data class Unknown(val unknownName: String) : Type(unknownName)
/** Someone mentioned you */
@Json(name = "mention")
MENTION("mention", R.string.notification_mention_name),
object Mention : Type("mention")
/** Someone boosted one of your statuses */
@Json(name = "reblog")
REBLOG("reblog", R.string.notification_boost_name),
object Reblog : Type("reblog")
/** Someone favourited one of your statuses */
@Json(name = "favourite")
FAVOURITE("favourite", R.string.notification_favourite_name),
object Favourite : Type("favourite")
/** Someone followed you */
@Json(name = "follow")
FOLLOW("follow", R.string.notification_follow_name),
object Follow : Type("follow")
/** Someone requested to follow you */
@Json(name = "follow_request")
FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name),
object FollowRequest : Type("follow_request")
/** A poll you have voted in or created has ended */
@Json(name = "poll")
POLL("poll", R.string.notification_poll_name),
object Poll : Type("poll")
/** Someone you enabled notifications for has posted a status */
@Json(name = "status")
STATUS("status", R.string.notification_subscription_name),
object Status : Type("status")
/** Someone signed up (optionally sent to admins) */
@Json(name = "admin.sign_up")
SIGN_UP("admin.sign_up", R.string.notification_sign_up_name),
object SignUp : Type("admin.sign_up")
/** A status you interacted with has been updated */
@Json(name = "update")
UPDATE("update", R.string.notification_update_name),
object Update : Type("update")
/** A new report has been filed */
@Json(name = "admin.report")
REPORT("admin.report", R.string.notification_report_name);
object Report : Type("admin.report")
companion object {
fun byString(s: String): Type {
return entries.firstOrNull { it.presentation == s } ?: UNKNOWN
}
/** Some of your follow relationships have been severed as a result of a moderation or block event **/
object SeveredRelationship : Type("severed_relationships")
/** Notification types for UI display (omits UNKNOWN) */
val visibleTypes =
listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT)
}
/** moderation_warning = A moderator has taken action against your account or has sent you a warning **/
object ModerationWarning : Type("moderation_warning")
override fun toString() = presentation
// can't use data objects or this wouldn't work
override fun toString() = name
}
// for Pleroma compatibility that uses Mention type
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
if (type == Type.MENTION && status != null) {
if (type == Mention && status != null) {
return if (status.mentions.any {
it.id == accountId
}
) {
this
} else {
copy(type = Type.STATUS)
copy(type = Type.Status)
}
}
return this
}
}
/** Notification types for UI display (omits UNKNOWN) */
/** this is not in a companion object so it gets initialized earlier,
* otherwise it might get initialized when a subclass is loaded,
* which leds to crash since those subclasses are referenced here */
val visibleNotificationTypes = listOf(Mention, Reblog, Favourite, Follow, FollowRequest, Type.Poll, Type.Status, SignUp, Update, Type.Report, SeveredRelationship, ModerationWarning)
fun notificationTypeFromString(s: String): Type {
return visibleNotificationTypes.firstOrNull { it.name == s.lowercase() } ?: Unknown(s)
}

View file

@ -22,5 +22,5 @@ import com.squareup.moshi.JsonClass
data class NotificationRequest(
val id: String,
val account: Account,
@Json(name = "notifications_count") val notificationsCount: String
@Json(name = "notifications_count") val notificationsCount: Int
)

View file

@ -0,0 +1,41 @@
/* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RelationshipSeveranceEvent(
val id: String,
val type: Type,
@Json(name = "target_name") val targetName: String,
@Json(name = "followers_count") val followersCount: Int,
@Json(name = "following_count") val followingCount: Int
) {
@JsonClass(generateAdapter = false)
enum class Type {
@Json(name = "domain_block")
DOMAIN_BLOCK,
@Json(name = "user_domain_block")
USER_DOMAIN_BLOCK,
@Json(name = "account_suspension")
ACCOUNT_SUSPENSION,
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.json
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.notificationTypeFromString
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class NotificationTypeAdapter : JsonAdapter<Notification.Type>() {
override fun fromJson(reader: JsonReader): Notification.Type {
return notificationTypeFromString(reader.nextString())
}
override fun toJson(writer: JsonWriter, value: Notification.Type?) {
writer.value(value?.name)
}
}

View file

@ -24,6 +24,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Status
@ -47,7 +48,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val senderId = intent.getLongExtra(NotificationService.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(
NotificationService.KEY_SENDER_ACCOUNT_IDENTIFIER
)
)!!
val senderFullName = intent.getStringExtra(
NotificationService.KEY_SENDER_ACCOUNT_FULL_NAME
)
@ -68,7 +69,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val notification = NotificationCompat.Builder(
context,
NotificationService.CHANNEL_MENTION + senderIdentifier
NotificationChannelData.MENTION.getChannelId(senderIdentifier)
)
.setSmallIcon(R.drawable.ic_notify)
.setColor(context.getColor(R.color.tusky_blue))
@ -113,7 +114,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
// Notifications with remote input active can't be cancelled, so let's replace it with another one that will dismiss automatically
val notification = NotificationCompat.Builder(
context,
NotificationService.CHANNEL_MENTION + senderIdentifier
NotificationChannelData.MENTION.getChannelId(senderIdentifier)
)
.setSmallIcon(R.drawable.ic_notify)
.setColor(context.getColor(R.color.notification_color))

View file

@ -45,7 +45,7 @@ enum class AppTheme(val value: String) {
*
* - Adding a new preference that does not change the interpretation of an existing preference
*/
const val SCHEMA_VERSION = 2025021701
const val SCHEMA_VERSION = 2025022001
/** The schema version for fresh installs */
const val NEW_INSTALL_SCHEMA_VERSION = 0
@ -98,15 +98,6 @@ object PrefKeys {
const val NOTIFICATION_ALERT_LIGHT = "notificationAlertLight"
const val NOTIFICATION_ALERT_VIBRATE = "notificationAlertVibrate"
const val NOTIFICATION_ALERT_SOUND = "notificationAlertSound"
const val NOTIFICATION_FILTER_POLLS = "notificationFilterPolls"
const val NOTIFICATION_FILTER_FAVS = "notificationFilterFavourites"
const val NOTIFICATION_FILTER_REBLOGS = "notificationFilterReblogs"
const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests"
const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows"
const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions"
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
const val NOTIFICATION_FILTER_REPORTS = "notificationFilterReports"
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"

View file

@ -3,21 +3,31 @@ 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.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity
import com.keylesspalace.tusky.entity.NotificationPolicy
import com.keylesspalace.tusky.network.MastodonApi
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
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 api: MastodonApi,
private val db: AppDatabase,
accountManager: AccountManager
) {
private val accountId = accountManager.activeAccount!!.id
private val _state: MutableStateFlow<NotificationPolicyState> = MutableStateFlow(NotificationPolicyState.Loading)
val state: StateFlow<NotificationPolicyState> = _state.asStateFlow()
val info: Flow<NotificationPolicyEntity?> = db.notificationPolicyDao().notificationPolicyForAccount(accountId)
suspend fun getNotificationPolicy() {
_state.value.let { state ->
if (state is NotificationPolicyState.Loaded) {
@ -29,6 +39,13 @@ class NotificationPolicyUsecase @Inject constructor(
api.notificationPolicy().fold(
{ policy ->
db.notificationPolicyDao().update(
NotificationPolicyEntity(
tuskyAccountId = accountId,
pendingRequestsCount = policy.summary.pendingRequestsCount,
pendingNotificationsCount = policy.summary.pendingNotificationsCount,
)
)
_state.value = NotificationPolicyState.Loaded(refreshing = false, policy = policy)
},
{ t ->
@ -58,6 +75,9 @@ class NotificationPolicyUsecase @Inject constructor(
_state.value = NotificationPolicyState.Loaded(false, notificationPolicy)
}
}
suspend fun updateCounts(notificationCount: Int) =
db.notificationPolicyDao().updateCounts(accountId, notificationCount)
}
sealed interface NotificationPolicyState {

View file

@ -14,7 +14,9 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.viewdata
import com.keylesspalace.tusky.entity.AccountWarning
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.TimelineAccount
@ -30,7 +32,9 @@ sealed class NotificationViewData {
val type: Notification.Type,
val account: TimelineAccount,
val statusViewData: StatusViewData.Concrete?,
val report: Report?
val report: Report?,
val event: RelationshipSeveranceEvent?,
val moderationWarning: AccountWarning?
) : NotificationViewData() {
override fun asStatusOrNull() = statusViewData