Remove unneeded code

This commit is contained in:
Lakoja 2023-09-11 21:58:56 +02:00
parent add62129f8
commit 4af160853d
16 changed files with 18 additions and 1937 deletions

View File

@ -10,12 +10,30 @@ import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.isLessThan
import kotlinx.coroutines.delay
import javax.inject.Inject
import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds
/** Models next/prev links from the "Links" header in an API response */
data class Links(val next: String?, val prev: String?) {
companion object {
fun from(linkHeader: String?): Links {
val links = HttpHeaderLink.parse(linkHeader)
return Links(
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
"max_id"
),
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
"min_id"
)
)
}
}
}
/**
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
*

View File

@ -1,38 +0,0 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter
/** Show load state and retry options when loading notifications */
class NotificationsLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<NotificationsLoadStateViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): NotificationsLoadStateViewHolder {
return NotificationsLoadStateViewHolder.create(parent, retry)
}
override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
}

View File

@ -1,73 +0,0 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding
import java.net.SocketTimeoutException
/**
* Display the header/footer loading state to the user.
*
* Either:
*
* 1. A page is being loaded, display a progress view, or
* 2. An error occurred, display an error message with a "retry" button
*
* @param retry function to invoke if the user clicks the "retry" button
*/
class NotificationsLoadStateViewHolder(
private val binding: ItemNotificationsLoadStateFooterViewBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry.invoke() }
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
val ctx = binding.root.context
binding.errorMsg.text = when (loadState.error) {
is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception)
// Other exceptions to consider:
// - UnknownHostException, default text is:
// Unable to resolve "%s": No address associated with hostname
else -> loadState.error.localizedMessage
}
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState is LoadState.Error
binding.errorMsg.isVisible = loadState is LoadState.Error
}
companion object {
fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder {
val binding = ItemNotificationsLoadStateFooterViewBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return NotificationsLoadStateViewHolder(binding, retry)
}
}
}

View File

@ -1,216 +0,0 @@
/*
* Copyright 2023 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.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import okhttp3.Headers
import retrofit2.Response
import javax.inject.Inject
/** Models next/prev links from the "Links" header in an API response */
data class Links(val next: String?, val prev: String?) {
companion object {
fun from(linkHeader: String?): Links {
val links = HttpHeaderLink.parse(linkHeader)
return Links(
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
"max_id"
),
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
"min_id"
)
)
}
}
}
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
class NotificationsPagingSource @Inject constructor(
private val mastodonApi: MastodonApi,
private val gson: Gson,
private val notificationFilter: Set<Notification.Type>
) : PagingSource<String, Notification>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
try {
val response = when (params) {
is LoadParams.Refresh -> {
getInitialPage(params)
}
is LoadParams.Append -> mastodonApi.notifications(
maxId = params.key,
limit = params.loadSize,
excludes = notificationFilter
)
is LoadParams.Prepend -> mastodonApi.notifications(
minId = params.key,
limit = params.loadSize,
excludes = notificationFilter
)
}
if (!response.isSuccessful) {
val code = response.code()
val msg = response.errorBody()?.string()?.let { errorBody ->
if (errorBody.isBlank()) return@let "no reason given"
val error = try {
gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java)
} catch (e: Exception) {
return@let "$errorBody ($e)"
}
when (val desc = error.error_description) {
null -> error.error
else -> "${error.error}: $desc"
}
} ?: "no reason given"
return LoadResult.Error(Throwable("HTTP $code: $msg"))
}
val links = Links.from(response.headers()["link"])
return LoadResult.Page(
data = response.body()!!,
nextKey = links.next,
prevKey = links.prev
)
} catch (e: Exception) {
return LoadResult.Error(e)
}
}
/**
* Fetch the initial page of notifications, using params.key as the ID of the initial
* notification to fetch.
*
* - If there is no key, a page of the most recent notifications is returned
* - If the notification exists, and is not filtered, a page of notifications is returned
* - If the notification does not exist, or is filtered, the page of notifications immediately
* before is returned (if non-empty)
* - If there is no page of notifications immediately before then the page immediately after
* is returned (if non-empty)
* - Finally, fall back to the most recent notifications
*/
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
// If the key is null this is straightforward, just return the most recent notifications.
val key = params.key
?: return@coroutineScope mastodonApi.notifications(
limit = params.loadSize,
excludes = notificationFilter
)
// It's important to return *something* from this state. If an empty page is returned
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
//
// In addition, the Mastodon API does not let you fetch a page that contains a given key.
// You can fetch the page immediately before the key, or the page immediately after, but
// you can not fetch the page itself.
// First, try and get the notification itself, and the notifications immediately before
// it. This is so that a full page of results can be returned. Returning just the
// single notification means the displayed list can jump around a bit as more data is
// loaded.
//
// Make both requests, and wait for the first to complete.
val deferredNotification = async { mastodonApi.notification(id = key) }
val deferredNotificationPage = async {
mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
}
val notification = deferredNotification.await()
if (notification.isSuccessful) {
// If this was successful we must still check that the user is not filtering this type
// of notification, as fetching a single notification ignores filters. Returning this
// notification if the user is filtering the type is wrong.
notification.body()?.let { body ->
if (!notificationFilter.contains(body.type)) {
// Notification is *not* filtered. We can return this, but need the next page of
// notifications as well
// Collect all notifications in to this list
val notifications = mutableListOf(body)
val notificationPage = deferredNotificationPage.await()
if (notificationPage.isSuccessful) {
notificationPage.body()?.let {
notifications.addAll(it)
}
}
// "notifications" now contains at least one notification we can return, and
// hopefully a full page.
// Build correct max_id and min_id links for the response. The "min_id" to use
// when fetching the next page is the same as "key". The "max_id" is the ID of
// the oldest notification in the list.
val maxId = notifications.last().id
val headers = Headers.Builder()
.add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
.build()
return@coroutineScope Response.success(notifications, headers)
}
}
}
// The user's last read notification was missing or is filtered. Use the page of
// notifications chronologically older than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops).
deferredNotificationPage.await().let { response ->
if (response.isSuccessful) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response
}
}
// There were no notifications older than the user's desired notification. Return the page
// of notifications immediately newer than their desired notification. This page must
// *not* be empty (as noted earlier, if it is, paging stops).
mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response ->
if (response.isSuccessful) {
if (!response.body().isNullOrEmpty()) return@coroutineScope response
}
}
// Everything failed -- fallback to fetching the most recent notifications
return@coroutineScope mastodonApi.notifications(
limit = params.loadSize,
excludes = notificationFilter
)
}
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
return state.anchorPosition?.let { anchorPosition ->
val id = state.closestItemToPosition(anchorPosition)?.id
Log.d(TAG, " getRefreshKey returning $id")
return id
}
}
companion object {
private const val TAG = "NotificationsPagingSource"
}
}

View File

@ -1,76 +0,0 @@
/*
* Copyright 2023 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.util.Log
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.Flow
import okhttp3.ResponseBody
import retrofit2.Response
import javax.inject.Inject
class NotificationsRepository @Inject constructor(
private val mastodonApi: MastodonApi,
private val gson: Gson
) {
private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
/**
* @return flow of Mastodon [Notification], excluding all types in [filter].
* Notifications are loaded in [pageSize] increments.
*/
fun getNotificationsStream(
filter: Set<Notification.Type>,
pageSize: Int = PAGE_SIZE,
initialKey: String? = null
): Flow<PagingData<Notification>> {
Log.d(TAG, "getNotificationsStream(), filtering: $filter")
factory = InvalidatingPagingSourceFactory {
NotificationsPagingSource(mastodonApi, gson, filter)
}
return Pager(
config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize),
initialKey = initialKey,
pagingSourceFactory = factory!!
).flow
}
/** Invalidate the active paging source, see [PagingSource.invalidate] */
fun invalidate() {
factory?.invalidate()
}
/** Clear notifications */
suspend fun clearNotifications(): Response<ResponseBody> {
return mastodonApi.clearNotifications()
}
companion object {
private const val TAG = "NotificationsRepository"
private const val PAGE_SIZE = 30
}
}

View File

@ -1,548 +0,0 @@
/*
* Copyright 2023 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.SharedPreferences
import android.util.Log
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteConversationEvent
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize
import com.keylesspalace.tusky.util.throttleFirst
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
data class UiState(
/** Filtered notification types */
val activeFilter: Set<Notification.Type> = emptySet(),
/** True if the FAB should be shown while scrolling */
val showFabWhileScrolling: Boolean = true
)
/** Preferences the UI reacts to */
data class UiPrefs(
val showFabWhileScrolling: Boolean
) {
companion object {
/** Relevant preference keys. Changes to any of these trigger a display update */
val prefKeys = setOf(
PrefKeys.FAB_HIDE
)
}
}
/** Parent class for all UI actions, fallible or infallible. */
sealed class UiAction
/** Actions the user can trigger from the UI. These actions may fail. */
sealed class FallibleUiAction : UiAction() {
/** Clear all notifications */
data object ClearNotifications : FallibleUiAction()
}
/**
* Actions the user can trigger from the UI that either cannot fail, or if they do fail,
* do not show an error.
*/
sealed class InfallibleUiAction : UiAction() {
/** Apply a new filter to the notification list */
// This saves the list to the local database, which triggers a refresh of the data.
// Saving the data can't fail, which is why this is infallible. Refreshing the
// data may fail, but that's handled by the paging system / adapter refresh logic.
data class ApplyFilter(val filter: Set<Notification.Type>) : InfallibleUiAction()
/**
* User is leaving the fragment, save the ID of the visible notification.
*
* Infallible because if it fails there's nowhere to show the error, and nothing the user
* can do.
*/
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
/** Ignore the saved reading position, load the page with the newest items */
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
// infallible. Reloading the data may fail, but that's handled by the paging system /
// adapter refresh logic.
data object LoadNewest : InfallibleUiAction()
}
/** Actions the user can trigger on an individual notification. These may fail. */
sealed class NotificationAction : FallibleUiAction() {
data class AcceptFollowRequest(val accountId: String) : NotificationAction()
data class RejectFollowRequest(val accountId: String) : NotificationAction()
}
sealed class UiSuccess {
// These three are from menu items on the status. Currently they don't come to the
// viewModel as actions, they're noticed when events are posted. That will change,
// but for the moment we can still report them to the UI. Typically, receiving any
// of these three should trigger the UI to refresh.
/** A user was blocked */
data object Block : UiSuccess()
/** A user was muted */
data object Mute : UiSuccess()
/** A conversation was muted */
data object MuteConversation : UiSuccess()
}
/** The result of a successful action on a notification */
sealed class NotificationActionSuccess(
/** String resource with an error message to show the user */
@StringRes val msg: Int,
/**
* The original action, in case additional information is required from it to display the
* message.
*/
open val action: NotificationAction
) : UiSuccess() {
data class AcceptFollowRequest(override val action: NotificationAction) :
NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action)
data class RejectFollowRequest(override val action: NotificationAction) :
NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action)
companion object {
fun from(action: NotificationAction) = when (action) {
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action)
}
}
}
/** Actions the user can trigger on an individual status */
sealed class StatusAction(
open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
/** Set the bookmark state for a status */
data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
StatusAction(statusViewData)
/** Set the favourite state for a status */
data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
StatusAction(statusViewData)
/** Set the reblog state for a status */
data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
StatusAction(statusViewData)
/** Vote in a poll */
data class VoteInPoll(
val poll: Poll,
val choices: List<Int>,
override val statusViewData: StatusViewData.Concrete
) : StatusAction(statusViewData)
}
/** Changes to a status' visible state after API calls */
sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
data class Bookmark(override val action: StatusAction.Bookmark) :
StatusActionSuccess(action)
data class Favourite(override val action: StatusAction.Favourite) :
StatusActionSuccess(action)
data class Reblog(override val action: StatusAction.Reblog) :
StatusActionSuccess(action)
data class VoteInPoll(override val action: StatusAction.VoteInPoll) :
StatusActionSuccess(action)
companion object {
fun from(action: StatusAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(action)
is StatusAction.Favourite -> Favourite(action)
is StatusAction.Reblog -> Reblog(action)
is StatusAction.VoteInPoll -> VoteInPoll(action)
}
}
}
/** Errors from fallible view model actions that the UI will need to show */
sealed class UiError(
/** The exception associated with the error */
open val throwable: Throwable,
/** String resource with an error message to show the user */
@StringRes val message: Int,
/** The action that failed. Can be resent to retry the action */
open val action: UiAction? = null
) {
data class ClearNotifications(override val throwable: Throwable) : UiError(
throwable,
R.string.ui_error_clear_notifications
)
data class Bookmark(
override val throwable: Throwable,
override val action: StatusAction.Bookmark
) : UiError(throwable, R.string.ui_error_bookmark, action)
data class Favourite(
override val throwable: Throwable,
override val action: StatusAction.Favourite
) : UiError(throwable, R.string.ui_error_favourite, action)
data class Reblog(
override val throwable: Throwable,
override val action: StatusAction.Reblog
) : UiError(throwable, R.string.ui_error_reblog, action)
data class VoteInPoll(
override val throwable: Throwable,
override val action: StatusAction.VoteInPoll
) : UiError(throwable, R.string.ui_error_vote, action)
data class AcceptFollowRequest(
override val throwable: Throwable,
override val action: NotificationAction.AcceptFollowRequest
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
data class RejectFollowRequest(
override val throwable: Throwable,
override val action: NotificationAction.RejectFollowRequest
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
companion object {
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(throwable, action)
is StatusAction.Favourite -> Favourite(throwable, action)
is StatusAction.Reblog -> Reblog(throwable, action)
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
private val preferences: SharedPreferences,
private val accountManager: AccountManager,
private val timelineCases: TimelineCases,
private val eventHub: EventHub
) : ViewModel() {
/** The account to display notifications for */
val account = accountManager.activeAccount!!
val uiState: StateFlow<UiState>
/** Flow of changes to statusDisplayOptions, for use by the UI */
val statusDisplayOptions: StateFlow<StatusDisplayOptions>
val pagingData: Flow<PagingData<NotificationViewData>>
/** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>()
/** Flow that can be used to trigger a full reload */
private val reload = MutableStateFlow(0)
/** Flow of successful action results */
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
// (e.g., after a device orientation change) should not re-show the most recent success
// message, as it will be confusing to the user.
val uiSuccess = MutableSharedFlow<UiSuccess>()
/** Channel for error results */
// Errors are sent to a channel to ensure that any errors that occur *before* there are any
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
// was a StateFlow any errors would be retained, and there would need to be an explicit
// mechanism to dismiss them.
private val _uiErrorChannel = Channel<UiError>()
/** Expose UI errors as a flow */
val uiError = _uiErrorChannel.receiveAsFlow()
/** Accept UI actions in to actionStateFlow */
val accept: (UiAction) -> Unit = { action ->
viewModelScope.launch { uiAction.emit(action) }
}
init {
// Handle changes to notification filters
val notificationFilter = uiAction
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
.distinctUntilChanged()
// Save each change back to the active account
.onEach { action ->
Log.d(TAG, "notificationFilter: $action")
account.notificationsFilter = serialize(action.filter)
accountManager.saveAccount(account)
}
// Load the initial filter from the active account
.onStart {
emit(
InfallibleUiAction.ApplyFilter(
filter = deserialize(account.notificationsFilter)
)
)
}
// Reset the last notification ID to "0" to fetch the newest notifications, and
// increment `reload` to trigger creation of a new PagingSource.
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest {
account.lastNotificationId = "0"
accountManager.saveAccount(account)
reload.getAndUpdate { it + 1 }
}
}
// Save the visible notification ID
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
.distinctUntilChanged()
.collectLatest { action ->
Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}")
account.lastNotificationId = action.visibleId
accountManager.saveAccount(account)
}
}
// Set initial status display options from the user's preferences.
//
// Then collect future preference changes and emit new values in to
// statusDisplayOptions if necessary.
statusDisplayOptions = MutableStateFlow(
StatusDisplayOptions.from(
preferences,
account
)
)
viewModelScope.launch {
eventHub.events
.filterIsInstance<PreferenceChangedEvent>()
.filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) }
.map {
statusDisplayOptions.value.make(
preferences,
it.preferenceKey,
account
)
}
.collect {
statusDisplayOptions.emit(it)
}
}
// Handle UiAction.ClearNotifications
viewModelScope.launch {
uiAction.filterIsInstance<FallibleUiAction.ClearNotifications>()
.collectLatest {
try {
repository.clearNotifications().apply {
if (this.isSuccessful) {
repository.invalidate()
} else {
_uiErrorChannel.send(UiError.make(HttpException(this), it))
}
}
} catch (e: Exception) {
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
}
}
}
// Handle NotificationAction.*
viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>()
.throttleFirst(THROTTLE_TIMEOUT)
.collect { action ->
try {
when (action) {
is NotificationAction.AcceptFollowRequest ->
timelineCases.acceptFollowRequest(action.accountId).await()
is NotificationAction.RejectFollowRequest ->
timelineCases.rejectFollowRequest(action.accountId).await()
}
uiSuccess.emit(NotificationActionSuccess.from(action))
} catch (e: Exception) {
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
}
}
}
// Handle StatusAction.*
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
.collect { action ->
try {
when (action) {
is StatusAction.Bookmark ->
timelineCases.bookmark(
action.statusViewData.actionableId,
action.state
)
is StatusAction.Favourite ->
timelineCases.favourite(
action.statusViewData.actionableId,
action.state
)
is StatusAction.Reblog ->
timelineCases.reblog(
action.statusViewData.actionableId,
action.state
)
is StatusAction.VoteInPoll ->
timelineCases.voteInPoll(
action.statusViewData.actionableId,
action.poll.id,
action.choices
)
}.getOrThrow()
uiSuccess.emit(StatusActionSuccess.from(action))
} catch (t: Throwable) {
_uiErrorChannel.send(UiError.make(t, action))
}
}
}
// Handle events that should refresh the list
viewModelScope.launch {
eventHub.events.collectLatest {
when (it) {
is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
}
}
}
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
// new items.
pagingData = combine(notificationFilter, reload) { action, _ -> action }
.flatMapLatest { action ->
getNotifications(filters = action.filter, initialKey = getInitialKey())
}.cachedIn(viewModelScope)
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState(
activeFilter = filter.filter,
showFabWhileScrolling = prefs.showFabWhileScrolling
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
}
private fun getNotifications(
filters: Set<Notification.Type>,
initialKey: String? = null
): Flow<PagingData<NotificationViewData>> {
return repository.getNotificationsStream(filter = filters, initialKey = initialKey)
.map { pagingData ->
pagingData.map { notification ->
notification.toViewData(
isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
!(notification.status?.actionableStatus?.sensitive ?: false),
isExpanded = statusDisplayOptions.value.openSpoiler,
isCollapsed = true
)
}
}
}
// The database stores "0" as the last notification ID if notifications have not been
// fetched. Convert to null to ensure a full fetch in this case
private fun getInitialKey(): String? {
val initialKey = when (val id = account.lastNotificationId) {
"0" -> null
else -> id
}
Log.d(TAG, "Restoring at $initialKey")
return initialKey
}
/**
* @return Flow of relevant preferences that change the UI
*/
// TODO: Preferences should be in a repository
private fun getUiPrefs() = eventHub.events
.filterIsInstance<PreferenceChangedEvent>()
.filter { UiPrefs.prefKeys.contains(it.preferenceKey) }
.map { toPrefs() }
.onStart { emit(toPrefs()) }
private fun toPrefs() = UiPrefs(
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false)
)
companion object {
private const val TAG = "NotificationsViewModel"
private val THROTTLE_TIMEOUT = 500.milliseconds
}
}

View File

@ -33,7 +33,6 @@ import com.keylesspalace.tusky.components.filters.EditFilterViewModel
import com.keylesspalace.tusky.components.filters.FiltersViewModel
import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel
import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
import com.keylesspalace.tusky.components.notifications.NotificationsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
@ -166,11 +165,6 @@ abstract class ViewModelModule {
@ViewModelKey(ListsForAccountViewModel::class)
internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(NotificationsViewModel::class)
internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(TrendingTagsViewModel::class)

View File

@ -1,143 +0,0 @@
/*
* Copyright 2023 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.paging.PagingSource
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
import retrofit2.Response
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class NotificationsPagingSourceTest {
@Test
fun `load() returns error message on HTTP error`() = runTest {
// Given
val jsonError = "{error: 'This is an error'}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: This is an error",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
// As previous, but with `error_description` field as well.
@Test
fun `load() returns extended error message on HTTP error`() = runTest {
// Given
val jsonError = "{error: 'This is an error', error_description: 'Description of the error'}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: This is an error: Description of the error",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
// As previous, but no error JSON, so expect default response
@Test
fun `load() returns default error message on empty HTTP error`() = runTest {
// Given
val jsonError = "{}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: no reason given",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
// As previous, but malformed JSON, so expect response with enough information to troubleshoot
@Test
fun `load() returns useful error message on malformed HTTP error`() = runTest {
// Given
val jsonError = "{'malformedjson}".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
val loadResult = pagingSource.load(loadingParams)
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals(
"HTTP 429: {'malformedjson} (com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Unterminated string at line 1 column 17 path \$.)",
(loadResult as PagingSource.LoadResult.Error).throwable.message
)
}
}

View File

@ -1,135 +0,0 @@
/*
* Copyright 2023 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.SharedPreferences
import android.os.Looper
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class MainCoroutineRule(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
abstract class NotificationsViewModelTestBase {
protected lateinit var notificationsRepository: NotificationsRepository
protected lateinit var sharedPreferencesMap: MutableMap<String, Boolean>
protected lateinit var sharedPreferences: SharedPreferences
protected lateinit var accountManager: AccountManager
protected lateinit var timelineCases: TimelineCases
protected lateinit var eventHub: EventHub
protected lateinit var viewModel: NotificationsViewModel
/** Empty success response, for API calls that return one */
protected var emptySuccess: Response<ResponseBody> = Response.success("".toResponseBody())
/** Empty error response, for API calls that return one */
protected var emptyError: Response<ResponseBody> = Response.error(404, "".toResponseBody())
/** Exception to throw when testing errors */
protected val httpException = HttpException(emptyError)
@get:Rule
val mainCoroutineRule = MainCoroutineRule()
@Before
fun setup() {
shadowOf(Looper.getMainLooper()).idle()
notificationsRepository = mock()
// Backing store for sharedPreferences, to allow mutation in tests
sharedPreferencesMap = mutableMapOf(
PrefKeys.ANIMATE_GIF_AVATARS to false,
PrefKeys.ANIMATE_CUSTOM_EMOJIS to false,
PrefKeys.ABSOLUTE_TIME_VIEW to false,
PrefKeys.SHOW_BOT_OVERLAY to true,
PrefKeys.USE_BLURHASH to true,
PrefKeys.CONFIRM_REBLOGS to true,
PrefKeys.CONFIRM_FAVOURITES to false,
PrefKeys.WELLBEING_HIDE_STATS_POSTS to false,
PrefKeys.FAB_HIDE to false
)
// Any getBoolean() call looks for the result in sharedPreferencesMap
sharedPreferences = mock {
on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] }
}
accountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.test",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true,
notificationsFilter = "['follow']",
mediaPreviewEnabled = true,
alwaysShowSensitiveMedia = true,
alwaysOpenSpoiler = true
)
}
eventHub = EventHub()
timelineCases = mock()
viewModel = NotificationsViewModel(
notificationsRepository,
sharedPreferences,
accountManager,
timelineCases,
eventHub
)
}
}

View File

@ -1,63 +0,0 @@
/*
* Copyright 2023 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [ClearNotifications] is handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Are the correct [NotificationsRepository] functions called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() {
@Test
fun `clearing notifications succeeds && invalidate the repository`() = runTest {
// Given
notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptySuccess }
// When
viewModel.accept(FallibleUiAction.ClearNotifications)
// Then
verify(notificationsRepository).clearNotifications()
verify(notificationsRepository).invalidate()
}
@Test
fun `clearing notifications fails && emits UiError`() = runTest {
// Given
notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptyError }
viewModel.uiError.test {
// When
viewModel.accept(FallibleUiAction.ClearNotifications)
// Then
assertThat(awaitItem()).isInstanceOf(UiError::class.java)
}
}
}

View File

@ -1,64 +0,0 @@
/*
* Copyright 2023 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
/**
* Verify that [ApplyFilter] is handled correctly on receipt:
*
* - Is the [UiState] updated correctly?
* - Are the correct [AccountManager] functions called, with the correct arguments?
*/
class NotificationsViewModelTestFilter : NotificationsViewModelTestBase() {
@Test
fun `should load initial filter from active account`() = runTest {
viewModel.uiState.test {
assertThat(awaitItem().activeFilter)
.containsExactlyElementsIn(setOf(Notification.Type.FOLLOW))
}
}
@Test
fun `should save filter to active account && update state`() = runTest {
viewModel.uiState.test {
// When
viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG)))
// Then
// - filter saved to active account
argumentCaptor<AccountEntity>().apply {
verify(accountManager).saveAccount(capture())
assertThat(this.lastValue.notificationsFilter)
.isEqualTo("[\"reblog\"]")
}
// - filter updated in uiState
assertThat(expectMostRecentItem().activeFilter)
.containsExactlyElementsIn(setOf(Notification.Type.REBLOG))
}
}
}

View File

@ -1,142 +0,0 @@
/*
* Copyright 2023 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.entity.Relationship
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [NotificationAction] are handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Is the correct [TimelineCases] function called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
class NotificationsViewModelTestNotificationAction : NotificationsViewModelTestBase() {
/** Dummy relationship */
private val relationship = Relationship(
// Nothing special about these values, it's just to have something to return
"1234",
following = true,
followedBy = true,
blocking = false,
muting = false,
mutingNotifications = false,
requested = false,
showingReblogs = false,
subscribing = null,
blockingDomain = false,
note = null,
notifying = null
)
/** Action to accept a follow request */
private val acceptAction = NotificationAction.AcceptFollowRequest("1234")
/** Action to reject a follow request */
private val rejectAction = NotificationAction.RejectFollowRequest("1234")
@Test
fun `accepting follow request succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { acceptFollowRequest(any()) } doReturn Single.just(relationship)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(acceptAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
assertThat((item as NotificationActionSuccess).action).isEqualTo(acceptAction)
}
// Then
argumentCaptor<String>().apply {
verify(timelineCases).acceptFollowRequest(capture())
assertThat(this.lastValue).isEqualTo("1234")
}
}
@Test
fun `accepting follow request fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { acceptFollowRequest(any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(acceptAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.AcceptFollowRequest::class.java)
assertThat(item.action).isEqualTo(acceptAction)
}
}
@Test
fun `rejecting follow request succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doReturn Single.just(relationship) }
viewModel.uiSuccess.test {
// When
viewModel.accept(rejectAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(NotificationActionSuccess::class.java)
assertThat((item as NotificationActionSuccess).action).isEqualTo(rejectAction)
}
// Then
argumentCaptor<String>().apply {
verify(timelineCases).rejectFollowRequest(capture())
assertThat(this.lastValue).isEqualTo("1234")
}
}
@Test
fun `rejecting follow request fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(rejectAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.RejectFollowRequest::class.java)
assertThat(item.action).isEqualTo(rejectAction)
}
}
}

View File

@ -1,225 +0,0 @@
/*
* Copyright 2023 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 app.cash.turbine.test
import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.FilterV1Test.Companion.mockStatus
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
/**
* Verify that [StatusAction] are handled correctly on receipt:
*
* - Is the correct [UiSuccess] or [UiError] value emitted?
* - Is the correct [TimelineCases] function called, with the correct arguments?
* This is only tested in the success case; if it passed there it must also
* have passed in the error case.
*/
class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() {
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
private val statusViewData = StatusViewData.Concrete(
status = status,
isExpanded = true,
isShowingContent = false,
isCollapsed = false
)
/** Action to bookmark a status */
private val bookmarkAction = StatusAction.Bookmark(true, statusViewData)
/** Action to favourite a status */
private val favouriteAction = StatusAction.Favourite(true, statusViewData)
/** Action to reblog a status */
private val reblogAction = StatusAction.Reblog(true, statusViewData)
/** Action to vote in a poll */
private val voteInPollAction = StatusAction.VoteInPoll(
poll = status.poll!!,
choices = listOf(1, 0, 0),
statusViewData
)
/** Captors for status ID and state arguments */
private val id = argumentCaptor<String>()
private val state = argumentCaptor<Boolean>()
@Test
fun `bookmark succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test {
// When
viewModel.accept(bookmarkAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction)
}
// Then
verify(timelineCases).bookmark(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `bookmark fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(bookmarkAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Bookmark::class.java)
assertThat(item.action).isEqualTo(bookmarkAction)
}
}
@Test
fun `favourite succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(favouriteAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction)
}
// Then
verify(timelineCases).favourite(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `favourite fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(favouriteAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Favourite::class.java)
assertThat(item.action).isEqualTo(favouriteAction)
}
}
@Test
fun `reblog succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) }
viewModel.uiSuccess.test {
// When
viewModel.accept(reblogAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction)
}
// Then
verify(timelineCases).reblog(id.capture(), state.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(state.firstValue).isEqualTo(true)
}
@Test
fun `reblog fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(reblogAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.Reblog::class.java)
assertThat(item.action).isEqualTo(reblogAction)
}
}
@Test
fun `voteinpoll succeeds && emits UiSuccess`() = runTest {
// Given
timelineCases.stub {
onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!)
}
viewModel.uiSuccess.test {
// When
viewModel.accept(voteInPollAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java)
assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction)
}
// Then
val pollId = argumentCaptor<String>()
val choices = argumentCaptor<List<Int>>()
verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture())
assertThat(id.firstValue).isEqualTo(statusViewData.status.id)
assertThat(pollId.firstValue).isEqualTo(status.poll!!.id)
assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices)
}
@Test
fun `voteinpoll fails && emits UiError`() = runTest {
// Given
timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException }
viewModel.uiError.test {
// When
viewModel.accept(voteInPollAction)
// Then
val item = awaitItem()
assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java)
assertThat(item.action).isEqualTo(voteInPollAction)
}
}
}

View File

@ -1,101 +0,0 @@
/*
* Copyright 2023 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Verify that [StatusDisplayOptions] are handled correctly.
*
* - Is the initial value taken from values in sharedPreferences and account?
* - Does the make() function correctly use an updated preference?
* - Is the correct update emitted when a relevant preference changes?
*/
class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() {
private val defaultStatusDisplayOptions = StatusDisplayOptions(
animateAvatars = false,
mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase
useAbsoluteTime = false,
showBotOverlay = true,
useBlurhash = true,
cardViewMode = CardViewMode.NONE,
confirmReblogs = true,
confirmFavourites = false,
hideStats = false,
animateEmojis = false,
showStatsInline = false,
showSensitiveMedia = true, // setting in NotificationsViewModelTestBase
openSpoiler = true // setting in NotificationsViewModelTestBase
)
@Test
fun `initial settings are from sharedPreferences and activeAccount`() = runTest {
viewModel.statusDisplayOptions.test {
val item = awaitItem()
assertThat(item).isEqualTo(defaultStatusDisplayOptions)
}
}
@Test
fun `make() uses updated preference`() = runTest {
// Prior, should be false
assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse()
// Given; just a change to one preferences
sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
// When
val updatedOptions = defaultStatusDisplayOptions.make(
sharedPreferences,
PrefKeys.ANIMATE_GIF_AVATARS,
accountManager.activeAccount!!
)
// Then, should be true
assertThat(updatedOptions.animateAvatars).isTrue()
}
@Test
fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest {
// Prior, should be false
viewModel.statusDisplayOptions.test {
val item = expectMostRecentItem()
assertThat(item.animateAvatars).isFalse()
}
// Given
sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS))
// Then, should be true
viewModel.statusDisplayOptions.test {
val item = expectMostRecentItem()
assertThat(item.animateAvatars).isTrue()
}
}
}

View File

@ -1,66 +0,0 @@
/*
* Copyright 2023 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* Verify that [UiState] is handled correctly.
*
* - Is the initial value taken from values in sharedPreferences and account?
* - Is the correct update emitted when a relevant preference changes?
*/
class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() {
private val initialUiState = UiState(
activeFilter = setOf(Notification.Type.FOLLOW),
showFabWhileScrolling = true
)
@Test
fun `should load initial filter from active account`() = runTest {
viewModel.uiState.test {
assertThat(expectMostRecentItem()).isEqualTo(initialUiState)
}
}
@Test
fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest {
// Prior
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue()
}
// Given
sharedPreferencesMap[PrefKeys.FAB_HIDE] = true
// When
eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE))
// Then
viewModel.uiState.test {
assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse()
}
}
}

View File

@ -1,41 +0,0 @@
/*
* Copyright 2023 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 com.google.common.truth.Truth.assertThat
import com.keylesspalace.tusky.db.AccountEntity
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() {
@Test
fun `should save notification ID to active account`() = runTest {
argumentCaptor<AccountEntity>().apply {
// When
viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
// Then
verify(accountManager).saveAccount(capture())
assertThat(this.lastValue.lastNotificationId)
.isEqualTo("1234")
}
}
}