Remove unneeded code
This commit is contained in:
parent
add62129f8
commit
4af160853d
16 changed files with 18 additions and 1937 deletions
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue