Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Initial support for filtering notifications by sending account #1127

Merged
merged 9 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import java.text.NumberFormat
import java.text.ParseException
import java.text.SimpleDateFormat
Expand All @@ -128,7 +129,13 @@ class AccountActivity :
@Inject
lateinit var clipboard: ClipboardUseCase

private val viewModel: AccountViewModel by viewModels()
private val viewModel: AccountViewModel by viewModels(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<AccountViewModel.Factory> { factory ->
factory.create(intent.pachliAccountId)
}
},
)

private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,21 @@ import app.pachli.util.Resource
import app.pachli.util.Success
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber

@HiltViewModel
class AccountViewModel @Inject constructor(
@HiltViewModel(assistedFactory = AccountViewModel.Factory::class)
class AccountViewModel @AssistedInject constructor(
@Assisted private val pachliAccountId: Long,
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
accountManager: AccountManager,
private val accountManager: AccountManager,
) : ViewModel() {

val accountData = MutableLiveData<Resource<Account>>()
Expand Down Expand Up @@ -266,7 +269,11 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Success(response.body))

when (relationshipAction) {
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
RelationShipAction.FOLLOW -> accountManager.followAccount(pachliAccountId, accountId)
RelationShipAction.UNFOLLOW -> {
accountManager.unfollowAccount(pachliAccountId, accountId)
eventHub.dispatch(UnfollowEvent(accountId))
}
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
else -> { }
Expand Down Expand Up @@ -327,4 +334,10 @@ class AccountViewModel @Inject constructor(
SUBSCRIBE,
UNSUBSCRIBE,
}

@AssistedFactory
interface Factory {
/** Creates [AccountViewModel] with [pachliAccountId] as the active account. */
fun create(pachliAccountId: Long): AccountViewModel
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* 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.
*
* Pachli 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 Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/

package app.pachli.components.notifications

import androidx.core.text.HtmlCompat
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.network.model.Notification
import app.pachli.databinding.ItemNotificationFilteredBinding
import app.pachli.viewdata.NotificationViewData

/**
* Viewholder for a notification that has been filtered to "warn".
*
* Displays:
*
* - The notification type and icon.
* - The domain the notification is from.
* - The reason the notification is filtered.
* - Buttons to edit the filter or show the notification.
*/
class FilterableNotificationViewHolder(
private val binding: ItemNotificationFilteredBinding,
private val localDomain: String,
private val notificationActionListener: NotificationActionListener,
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
private val context = binding.root.context

lateinit var viewData: NotificationViewData

private val notFollowing = HtmlCompat.fromHtml(
context.getString(R.string.account_filter_placeholder_label_not_following),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)

private val younger30d = HtmlCompat.fromHtml(
context.getString(R.string.account_filter_placeholder_label_younger_30d),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)

private val limitedByServer = HtmlCompat.fromHtml(
context.getString(R.string.account_filter_placeholder_label_limited_by_server),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)

init {
binding.accountFilterShowAnyway.setOnClickListener {
notificationActionListener.clearAccountFilter(viewData)
}

binding.accountFilterEditFilter.setOnClickListener {
notificationActionListener.editAccountNotificationFilter()
}
}

override fun bind(pachliAccountId: Long, viewData: NotificationViewData, payloads: List<*>?, statusDisplayOptions: StatusDisplayOptions) {
this.viewData = viewData

val icon = viewData.type.icon(context)

// Labels for different notification types filtered by account.
val label = when (viewData.type) {
Notification.Type.MENTION -> R.string.account_filter_placeholder_type_mention_fmt
Notification.Type.REBLOG -> R.string.account_filter_placeholder_type_reblog_fmt
Notification.Type.FAVOURITE -> R.string.account_filter_placeholder_type_favourite_fmt
Notification.Type.FOLLOW -> R.string.account_filter_placeholder_type_follow_fmt
Notification.Type.FOLLOW_REQUEST -> R.string.account_filter_placeholder_type_follow_request_fmt
else -> R.string.account_filter_placeholder_label_domain
}

binding.accountFilterDomain.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
binding.accountFilterDomain.text = HtmlCompat.fromHtml(
context.getString(
label,
viewData.account.domain.ifEmpty { localDomain },
),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)

val reason = when (viewData.accountFilterDecision?.reason) {
AccountFilterReason.NOT_FOLLOWING -> notFollowing
AccountFilterReason.YOUNGER_30D -> younger30d
AccountFilterReason.LIMITED_BY_SERVER -> limitedByServer
null -> ""
}
binding.accountFilterReason.text = reason
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import app.pachli.core.activity.NotificationConfig
import app.pachli.core.common.string.isLessThan
import app.pachli.core.data.repository.AccountManager
import app.pachli.core.database.model.AccountEntity
import app.pachli.core.model.FilterAction
import app.pachli.core.network.model.Links
import app.pachli.core.network.model.Marker
import app.pachli.core.network.model.Notification
Expand Down Expand Up @@ -61,27 +62,32 @@ class NotificationFetcher @Inject constructor(
suspend fun fetchAndShow(pachliAccountId: Long) {
Timber.d("NotificationFetcher.fetchAndShow(%d) started", pachliAccountId)

val accounts = buildList {
val pachliAccounts = buildList {
if (pachliAccountId == NotificationWorker.ALL_ACCOUNTS) {
addAll(accountManager.accountsOrderedByActiveFlow.take(1).first())
addAll(accountManager.pachliAccountsFlow.take(1).first())
} else {
accountManager.getAccountById(pachliAccountId)?.let { add(it) }
accountManager.getPachliAccountFlow(pachliAccountId).take(1).first()?.let { add(it) }
}
}

for (account in accounts) {
for (pachliAccount in pachliAccounts) {
val entity = pachliAccount.entity
Timber.d(
"Checking %s, notificationsEnabled = %s",
account.fullName,
account.notificationsEnabled,
entity.fullName,
entity.notificationsEnabled,
)
if (account.notificationsEnabled) {
if (entity.notificationsEnabled) {
try {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

// Create sorted list of new notifications
val notifications = fetchNewNotifications(account)
.filter { filterNotification(notificationManager, account, it.type) }
val notifications = fetchNewNotifications(entity)
.filter { filterNotification(notificationManager, entity, it.type) }
.filter {
val decision = filterNotificationByAccount(pachliAccount, it)
decision == null || decision.action == FilterAction.NONE
}
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
.toMutableList()

Expand Down Expand Up @@ -118,10 +124,10 @@ class NotificationFetcher @Inject constructor(
context,
notificationManager,
notification,
account,
entity,
index == 0,
)
notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
notificationManager.notify(notification.id, entity.id.toInt(), androidNotification)
// Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
Expand All @@ -131,7 +137,7 @@ class NotificationFetcher @Inject constructor(
updateSummaryNotifications(
context,
notificationManager,
account,
entity,
)
} catch (e: Exception) {
Timber.e(e, "Error while fetching notifications")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ import app.pachli.MainActivity
import app.pachli.R
import app.pachli.core.activity.NotificationConfig
import app.pachli.core.common.string.unicodeWrap
import app.pachli.core.data.repository.PachliAccount
import app.pachli.core.database.model.AccountEntity
import app.pachli.core.designsystem.R as DR
import app.pachli.core.model.FilterAction
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
import app.pachli.core.navigation.MainActivityIntent
import app.pachli.core.network.model.Notification
Expand All @@ -58,6 +60,7 @@ import app.pachli.viewdata.calculatePercent
import app.pachli.worker.NotificationWorker
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import java.time.Duration
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import timber.log.Timber
Expand Down Expand Up @@ -648,6 +651,103 @@ fun filterNotification(
}
}

/** Reasons an account may be filtered. */
enum class AccountFilterReason {
/** Not following this account. */
NOT_FOLLOWING,

/** Account is younger than 30d. */
YOUNGER_30D,

/** Account is limited by the server. */
LIMITED_BY_SERVER,
}

/**
* Records an account filtering decision.
*
* @param action The [FilterAction] to perform.
* @param reason The [AccountFilterReason] for the decision.
*/
data class AccountFilterDecision(
val action: FilterAction,
val reason: AccountFilterReason,
)

/**
* Returns the [AccountFilterDecision] for [notification] based on the notification
* filters in [accountWithFilters].
*
* @return The decision, or null if the notification should not be filtered.
*/
fun filterNotificationByAccount(accountWithFilters: PachliAccount, notification: Notification): AccountFilterDecision? {
// Some notifications are never filtered, irrespective of the account that
// sent them.
when (notification.type) {
// Poll we interacted with has ended.
Notification.Type.POLL -> return null
// Status we interacted with has been updated.
Notification.Type.UPDATE -> return null
// A new moderation report.
Notification.Type.REPORT -> return null
// Moderation has resulted in severed relationships.
Notification.Type.SEVERED_RELATIONSHIPS -> return null
// We explicitly asked to be notified about this user.
Notification.Type.STATUS -> return null
// Admin signup notifications should not be filtered.
Notification.Type.SIGN_UP -> return null
else -> {
/* fall through */
}
}

// The account that generated the notification.
val accountToTest = notification.account

// Any notifications from our own activity are not filtered.
if (accountWithFilters.entity.accountId == accountToTest.id) return null

val reasons = buildList {
// Check the following relationship.
if (accountWithFilters.entity.notificationAccountFilterNotFollowed != FilterAction.NONE) {
if (accountWithFilters.following.none { it.serverId == accountToTest.id }) {
add(
AccountFilterDecision(
action = accountWithFilters.entity.notificationAccountFilterNotFollowed,
reason = AccountFilterReason.NOT_FOLLOWING,
),
)
}
}

// Check the age of the account relative to the notification.
accountToTest.createdAt?.let { createdAt ->
if (accountWithFilters.entity.notificationAccountFilterYounger30d != FilterAction.NONE) {
if (Duration.between(createdAt, notification.createdAt.toInstant()) < Duration.ofDays(30)) {
add(
AccountFilterDecision(
action = accountWithFilters.entity.notificationAccountFilterYounger30d,
reason = AccountFilterReason.YOUNGER_30D,
),
)
}
}
}

// Check limited status.
if (accountToTest.limited && accountWithFilters.entity.notificationAccountFilterLimitedByServer != FilterAction.NONE) {
add(
AccountFilterDecision(
action = accountWithFilters.entity.notificationAccountFilterLimitedByServer,
reason = AccountFilterReason.LIMITED_BY_SERVER,
),
)
}
}

return reasons.maxByOrNull { it.action }
}

private fun getChannelId(account: AccountEntity, notification: Notification): String? {
return getChannelId(account, notification.type)
}
Expand Down
Loading
Loading