refactor: Ongoing work to remove the `activeAccount` idiom (#964)
Continue the work to remove the "activeAccount" idiom. - Uses a new PachliAccount type through most of the app. This holds information that was previously accessed separately (e.g., content filters, lists) in one place. The information is loaded when the app launches or the active account switches. - Fetching data when the account is switched / loaded simplifies error handling, as more code can now assume the data has already been loaded. If it hasn't the code path is simply unreachable. - This opens up the possibility of "acting as one account while logged in as another". E.g., have two accounts, and be logged in to one account and boost a post you've seen from your other account. - Add a database migration to populate existing accounts with default data when the user updates the app. - Refactor code that used those list and filter repositories to get the data from the PachliAccount instead. New local and remote data sources are implemented, and the list and filter repositories mediate between those sources. - Start a ViewModel for MainActivity, which includes: - Sending user actions as UiAction objects - Providing a flow of uiState for MainActivity to react to - Remove most uses of SharedPreferencesRepository from MainActivity - Show messages about errors that occur when logging in - Refactor intent routing in MainActivity to make the logic clearer. - Add new `core.data` types to push more `core.network` types out of the UI code - `core.data.model.MastodonList` for `core.network.model.MastoList` - `core.data.model.Server` for `core.network.model.Server` - Continue the work to send the Pachli account ID to the code that uses it. - Most view models now get the account ID via assisted injection. - QueuedMedia now includes the AccountEntity so it can operate with any account. Modify the `uploadMedia` API call to include explicit authentication details. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
This commit is contained in:
parent
6c178b5e8b
commit
710e209e34
|
@ -712,7 +712,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="629"
|
||||
line="626"
|
||||
column="43"/>
|
||||
</issue>
|
||||
|
||||
|
@ -723,7 +723,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="721"
|
||||
line="718"
|
||||
column="51"/>
|
||||
</issue>
|
||||
|
||||
|
@ -734,7 +734,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="745"
|
||||
line="742"
|
||||
column="68"/>
|
||||
</issue>
|
||||
|
||||
|
@ -800,7 +800,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="444"
|
||||
line="442"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -811,7 +811,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="617"
|
||||
line="614"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1383,7 +1383,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="410"
|
||||
line="408"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1394,7 +1394,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="413"
|
||||
line="411"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1405,7 +1405,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="414"
|
||||
line="412"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1416,7 +1416,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="415"
|
||||
line="413"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1427,7 +1427,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="417"
|
||||
line="415"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1438,7 +1438,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="429"
|
||||
line="427"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1449,7 +1449,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="430"
|
||||
line="428"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1460,7 +1460,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="482"
|
||||
line="480"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1471,7 +1471,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="483"
|
||||
line="481"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1482,7 +1482,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="494"
|
||||
line="491"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1493,7 +1493,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="495"
|
||||
line="492"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1504,7 +1504,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="496"
|
||||
line="493"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1515,7 +1515,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="499"
|
||||
line="496"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1526,7 +1526,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="537"
|
||||
line="534"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1537,7 +1537,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="579"
|
||||
line="576"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1548,7 +1548,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="585"
|
||||
line="582"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1559,7 +1559,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="609"
|
||||
line="606"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1570,7 +1570,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="635"
|
||||
line="632"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1687,7 +1687,7 @@
|
|||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/activity_compose.xml"
|
||||
line="368"
|
||||
line="369"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
|
@ -2541,12 +2541,12 @@
|
|||
<issue
|
||||
id="ReportShortcutUsage"
|
||||
message="Calling this method indicates use of dynamic shortcuts, but there are no calls to methods that track shortcut usage, such as `pushDynamicShortcut` or `reportShortcutUsed`. Calling these methods is recommended, as they track shortcut usage and allow launchers to adjust which shortcuts appear based on activation history. Please see https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#track-usage"
|
||||
errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/app/pachli/util/ShareShortcutHelper.kt"
|
||||
line="96"
|
||||
column="5"/>
|
||||
file="src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt"
|
||||
line="106"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
|
||||
# keep members of our model classes, they are used in json de/serialization
|
||||
-keepclassmembers class app.pachli.core.network.model.** { *; }
|
||||
-keepclassmembers class app.pachli.core.model.** { *; }
|
||||
|
||||
-keep public enum app.pachli.core.network.model.*$** {
|
||||
**[] $VALUES;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.core.common.PachliError
|
||||
import app.pachli.core.data.model.Server
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.RefreshAccountError
|
||||
import app.pachli.core.data.repository.SetActiveAccountError
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.ServerOperation
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.preferences.MainNavigationPosition
|
||||
import app.pachli.core.preferences.PrefKeys
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.preferences.ShowSelfUsername
|
||||
import app.pachli.core.preferences.TabAlignment
|
||||
import app.pachli.core.preferences.TabContents
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.mapEither
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Actions the user can take from the UI. */
|
||||
internal sealed interface UiAction
|
||||
|
||||
internal sealed interface FallibleUiAction : UiAction {
|
||||
data class SetActiveAccount(val pachliAccountId: Long) : FallibleUiAction
|
||||
data class RefreshAccount(val accountEntity: AccountEntity) : FallibleUiAction
|
||||
}
|
||||
|
||||
internal sealed interface InfallibleUiAction : UiAction {
|
||||
/** Remove [timeline] from the active account's tabs. */
|
||||
data class TabRemoveTimeline(val timeline: Timeline) : InfallibleUiAction
|
||||
}
|
||||
|
||||
/** Actions that succeeded. */
|
||||
internal sealed interface UiSuccess {
|
||||
val action: FallibleUiAction
|
||||
|
||||
data class SetActiveAccount(
|
||||
override val action: FallibleUiAction.SetActiveAccount,
|
||||
val accountEntity: AccountEntity,
|
||||
) : UiSuccess
|
||||
|
||||
data class RefreshAccount(
|
||||
override val action: FallibleUiAction.RefreshAccount,
|
||||
) : UiSuccess
|
||||
}
|
||||
|
||||
/** Actions that failed. */
|
||||
internal sealed class UiError(
|
||||
@StringRes override val resourceId: Int,
|
||||
open val action: UiAction,
|
||||
override val cause: PachliError,
|
||||
override val formatArgs: Array<out String>? = null,
|
||||
) : PachliError {
|
||||
data class SetActiveAccount(
|
||||
override val action: FallibleUiAction.SetActiveAccount,
|
||||
override val cause: SetActiveAccountError,
|
||||
) : UiError(R.string.main_viewmodel_error_set_active_account, action, cause)
|
||||
|
||||
data class RefreshAccount(
|
||||
override val action: FallibleUiAction.RefreshAccount,
|
||||
override val cause: RefreshAccountError,
|
||||
) : UiError(R.string.main_viewmodel_error_refresh_account, action, cause)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param animateAvatars See [SharedPreferencesRepository.animateAvatars].
|
||||
* @param animateEmojis See [SharedPreferencesRepository.animateEmojis].
|
||||
* @param enableTabSwipe See [SharedPreferencesRepository.enableTabSwipe].
|
||||
* @param hideTopToolbar See [SharedPreferencesRepository.hideTopToolbar].
|
||||
* @param mainNavigationPosition See [SharedPreferencesRepository.mainNavigationPosition].
|
||||
* @param displaySelfUsername See [ShowSelfUsername].
|
||||
* @param accounts Unordered list of available accounts.
|
||||
* @param canSchedulePost True if the account can schedule posts
|
||||
*/
|
||||
data class UiState(
|
||||
val animateAvatars: Boolean,
|
||||
val animateEmojis: Boolean,
|
||||
val enableTabSwipe: Boolean,
|
||||
val hideTopToolbar: Boolean,
|
||||
val mainNavigationPosition: MainNavigationPosition,
|
||||
val displaySelfUsername: Boolean,
|
||||
val accounts: List<AccountEntity>,
|
||||
val canSchedulePost: Boolean,
|
||||
val tabAlignment: TabAlignment,
|
||||
val tabContents: TabContents,
|
||||
) {
|
||||
companion object {
|
||||
fun make(prefs: SharedPreferencesRepository, accounts: List<AccountEntity>, server: Server?) = UiState(
|
||||
animateAvatars = prefs.animateAvatars,
|
||||
animateEmojis = prefs.animateEmojis,
|
||||
enableTabSwipe = prefs.enableTabSwipe,
|
||||
hideTopToolbar = prefs.hideTopToolbar,
|
||||
mainNavigationPosition = prefs.mainNavigationPosition,
|
||||
displaySelfUsername = when (prefs.showSelfUsername) {
|
||||
ShowSelfUsername.ALWAYS -> true
|
||||
ShowSelfUsername.DISAMBIGUATE -> accounts.size > 1
|
||||
ShowSelfUsername.NEVER -> false
|
||||
},
|
||||
accounts = accounts,
|
||||
canSchedulePost = server?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true,
|
||||
tabAlignment = prefs.tabAlignment,
|
||||
tabContents = prefs.tabContents,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@HiltViewModel()
|
||||
internal class MainViewModel @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
) : ViewModel() {
|
||||
/**
|
||||
* Flow of Pachli Account IDs, the most recent entry in the flow is the active account.
|
||||
*
|
||||
* Initially null, the activity sets this by sending [FallibleUiAction.SetActiveAccount].
|
||||
*/
|
||||
private val pachliAccountIdFlow = MutableStateFlow<Long?>(null)
|
||||
|
||||
val pachliAccountFlow = pachliAccountIdFlow.filterNotNull().flatMapLatest { accountId ->
|
||||
accountManager.getPachliAccountFlow(accountId)
|
||||
.filterNotNull()
|
||||
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
|
||||
|
||||
private val uiAction = MutableSharedFlow<UiAction>()
|
||||
|
||||
val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } }
|
||||
|
||||
private val _uiResult = Channel<Result<UiSuccess, UiError>>()
|
||||
val uiResult = _uiResult.receiveAsFlow()
|
||||
|
||||
private val watchedPrefs = setOf(
|
||||
PrefKeys.ANIMATE_GIF_AVATARS,
|
||||
PrefKeys.ANIMATE_CUSTOM_EMOJIS,
|
||||
PrefKeys.ENABLE_SWIPE_FOR_TABS,
|
||||
PrefKeys.HIDE_TOP_TOOLBAR,
|
||||
PrefKeys.MAIN_NAV_POSITION,
|
||||
PrefKeys.SHOW_SELF_USERNAME,
|
||||
PrefKeys.TAB_ALIGNMENT,
|
||||
PrefKeys.TAB_CONTENTS,
|
||||
)
|
||||
|
||||
val uiState =
|
||||
combine(
|
||||
sharedPreferencesRepository.changes.filter { watchedPrefs.contains(it) }.onStart { emit(null) },
|
||||
accountManager.accountsFlow,
|
||||
pachliAccountFlow,
|
||||
) { _, accounts, pachliAccount ->
|
||||
UiState.make(
|
||||
sharedPreferencesRepository,
|
||||
accounts,
|
||||
pachliAccount.server,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.make(sharedPreferencesRepository, accountManager.accounts, null),
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { uiAction.collect { launch { onUiAction(it) } } }
|
||||
}
|
||||
|
||||
private suspend fun onUiAction(uiAction: UiAction) {
|
||||
if (uiAction is InfallibleUiAction) {
|
||||
when (uiAction) {
|
||||
is InfallibleUiAction.TabRemoveTimeline -> onTabRemoveTimeline(uiAction.timeline)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiAction is FallibleUiAction) {
|
||||
val result = when (uiAction) {
|
||||
is FallibleUiAction.SetActiveAccount -> onSetActiveAccount(uiAction)
|
||||
is FallibleUiAction.RefreshAccount -> onRefreshAccount(uiAction)
|
||||
}
|
||||
_uiResult.send(result)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onSetActiveAccount(action: FallibleUiAction.SetActiveAccount): Result<UiSuccess.SetActiveAccount, UiError.SetActiveAccount> {
|
||||
return accountManager.setActiveAccount(action.pachliAccountId)
|
||||
.mapEither(
|
||||
{ UiSuccess.SetActiveAccount(action, it) },
|
||||
{ UiError.SetActiveAccount(action, it) },
|
||||
)
|
||||
.onSuccess {
|
||||
pachliAccountIdFlow.value = it.accountEntity.id
|
||||
uiAction.emit(FallibleUiAction.RefreshAccount(it.accountEntity))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onRefreshAccount(action: FallibleUiAction.RefreshAccount): Result<UiSuccess.RefreshAccount, UiError.RefreshAccount> {
|
||||
return accountManager.refresh(action.accountEntity)
|
||||
.mapEither(
|
||||
{ UiSuccess.RefreshAccount(action) },
|
||||
{ UiError.RefreshAccount(action, it) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onTabRemoveTimeline(timeline: Timeline) {
|
||||
val active = pachliAccountFlow.replayCache.last().entity
|
||||
val tabPreferences = active.tabPreferences.filterNot { it == timeline }
|
||||
accountManager.setTabPreferences(active.id, tabPreferences)
|
||||
}
|
||||
}
|
|
@ -37,42 +37,33 @@ import androidx.transition.TransitionManager
|
|||
import app.pachli.adapter.ItemInteractionListener
|
||||
import app.pachli.adapter.TabAdapter
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.MainTabsChangedEvent
|
||||
import app.pachli.core.activity.BaseActivity
|
||||
import app.pachli.core.common.extensions.show
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.extensions.visible
|
||||
import app.pachli.core.common.util.unsafeLazy
|
||||
import app.pachli.core.data.repository.Lists
|
||||
import app.pachli.core.data.model.MastodonList
|
||||
import app.pachli.core.data.repository.ListsRepository
|
||||
import app.pachli.core.data.repository.ListsRepository.Companion.compareByListTitle
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.navigation.ListsActivityIntent
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
import app.pachli.core.network.model.MastoList
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.databinding.ActivityTabPreferenceBinding
|
||||
import app.pachli.databinding.DialogSelectListBinding
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
|
@ -86,8 +77,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
|
|||
private lateinit var touchHelper: ItemTouchHelper
|
||||
private lateinit var addTabAdapter: TabAdapter
|
||||
|
||||
private var tabsChanged = false
|
||||
|
||||
private val selectedItemElevation by unsafeLazy { resources.getDimension(DR.dimen.selected_drag_item_elevation) }
|
||||
|
||||
private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
|
||||
|
@ -281,7 +270,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
|
|||
}
|
||||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
|
||||
val adapter = object : ArrayAdapter<MastodonList>(this, android.R.layout.simple_list_item_1) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as TextView).text = item.title }
|
||||
|
@ -300,7 +289,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
|
|||
adapter.getItem(position)?.let { item ->
|
||||
val newTab = TabViewData.from(
|
||||
intent.pachliAccountId,
|
||||
Timeline.UserList(item.id, item.title),
|
||||
Timeline.UserList(item.listId, item.title),
|
||||
)
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
|
@ -324,21 +313,11 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
|
|||
dialog.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
listsRepository.lists.collect { result ->
|
||||
result.onSuccess { lists ->
|
||||
if (lists is Lists.Loaded) {
|
||||
selectListBinding.progressBar.hide()
|
||||
adapter.clear()
|
||||
adapter.addAll(lists.lists.sortedWith(compareByListTitle))
|
||||
if (lists.lists.isEmpty()) selectListBinding.noLists.show()
|
||||
}
|
||||
}
|
||||
|
||||
result.onFailure {
|
||||
selectListBinding.progressBar.hide()
|
||||
dialog.dismiss()
|
||||
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
listsRepository.getLists(intent.pachliAccountId).collectLatest { lists ->
|
||||
selectListBinding.progressBar.hide()
|
||||
adapter.clear()
|
||||
adapter.addAll(lists.sortedWith(compareByListTitle))
|
||||
if (lists.isEmpty()) selectListBinding.noLists.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -410,18 +389,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener {
|
|||
private fun saveTabs() {
|
||||
accountManager.activeAccount?.let {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
it.tabPreferences = currentTabs.map { it.timeline }
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
tabsChanged = true
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (tabsChanged) {
|
||||
lifecycleScope.launch {
|
||||
eventHub.dispatch(MainTabsChangedEvent(currentTabs.map { it.timeline }))
|
||||
accountManager.setTabPreferences(it.id, currentTabs.map { it.timeline })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ data class TabViewData(
|
|||
@DrawableRes val icon: Int,
|
||||
val fragment: () -> Fragment,
|
||||
val title: (Context) -> String = { context -> context.getString(text) },
|
||||
val composeIntent: ((Context) -> Intent)? = { context -> ComposeActivityIntent(context) },
|
||||
val composeIntent: ((Context, Long) -> Intent)? = { context, pachliAccountId -> ComposeActivityIntent(context, pachliAccountId) },
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
@ -94,9 +94,10 @@ data class TabViewData(
|
|||
text = R.string.title_direct_messages,
|
||||
icon = R.drawable.ic_reblog_direct_24dp,
|
||||
fragment = { ConversationsFragment.newInstance(pachliAccountId) },
|
||||
) {
|
||||
) { context, pachliAccountId ->
|
||||
ComposeActivityIntent(
|
||||
it,
|
||||
context,
|
||||
pachliAccountId,
|
||||
ComposeActivityIntent.ComposeOptions(visibility = Status.Visibility.PRIVATE),
|
||||
)
|
||||
}
|
||||
|
@ -129,10 +130,11 @@ data class TabViewData(
|
|||
context.getString(R.string.title_tag, it)
|
||||
}
|
||||
},
|
||||
) { context ->
|
||||
) { context, pachliAccountId ->
|
||||
val tag = timeline.tags.first()
|
||||
ComposeActivityIntent(
|
||||
context,
|
||||
pachliAccountId,
|
||||
ComposeActivityIntent.ComposeOptions(
|
||||
content = getString(context, R.string.title_tag_with_initial_position).format(tag),
|
||||
initialCursorPosition = ComposeActivityIntent.ComposeOptions.InitialCursorPosition.START,
|
||||
|
|
|
@ -26,13 +26,13 @@ import androidx.core.view.MenuProvider
|
|||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.MainTabsChangedEvent
|
||||
import app.pachli.core.activity.BottomSheetActivity
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.util.unsafeLazy
|
||||
import app.pachli.core.data.repository.ContentFilterEdit
|
||||
import app.pachli.core.data.repository.ContentFiltersError
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.canFilterV1
|
||||
import app.pachli.core.data.repository.canFilterV2
|
||||
import app.pachli.core.model.ContentFilter
|
||||
import app.pachli.core.model.FilterAction
|
||||
import app.pachli.core.model.FilterContext
|
||||
|
@ -53,6 +53,8 @@ import com.google.android.material.snackbar.Snackbar
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -116,7 +118,14 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
}
|
||||
|
||||
viewData.composeIntent?.let { intent ->
|
||||
binding.composeButton.setOnClickListener { startActivity(intent(this@TimelineActivity)) }
|
||||
binding.composeButton.setOnClickListener {
|
||||
startActivity(
|
||||
intent(
|
||||
this@TimelineActivity,
|
||||
this.intent.pachliAccountId,
|
||||
),
|
||||
)
|
||||
}
|
||||
binding.composeButton.show()
|
||||
} ?: binding.composeButton.hide()
|
||||
}
|
||||
|
@ -185,9 +194,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
private fun addToTab() {
|
||||
accountManager.activeAccount?.let {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
it.tabPreferences += timeline
|
||||
accountManager.saveAccount(it)
|
||||
eventHub.dispatch(MainTabsChangedEvent(it.tabPreferences))
|
||||
accountManager.setTabPreferences(it.id, it.tabPreferences + timeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -243,23 +250,23 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
unmuteTagItem?.isVisible = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
contentFiltersRepository.contentFilters.collect { result ->
|
||||
result.onSuccess { filters ->
|
||||
mutedContentFilter = filters?.contentFilters?.firstOrNull { filter ->
|
||||
filter.contexts.contains(FilterContext.HOME) &&
|
||||
filter.keywords.any { it.keyword == tagWithHash }
|
||||
}
|
||||
updateTagMuteState(mutedContentFilter != null)
|
||||
}
|
||||
result.onFailure { error ->
|
||||
// If the server can't filter then it's impossible to mute hashtags,
|
||||
// so disable the functionality.
|
||||
if (error is ContentFiltersError.ServerDoesNotFilter) {
|
||||
accountManager.getPachliAccountFlow(intent.pachliAccountId)
|
||||
.filterNotNull()
|
||||
.distinctUntilChangedBy { it.contentFilters }
|
||||
.collect { account ->
|
||||
if (account.server.canFilterV2() || account.server.canFilterV1()) {
|
||||
mutedContentFilter = account.contentFilters.contentFilters.firstOrNull { filter ->
|
||||
filter.contexts.contains(FilterContext.HOME) &&
|
||||
filter.keywords.any { it.keyword == tagWithHash }
|
||||
}
|
||||
updateTagMuteState(mutedContentFilter != null)
|
||||
} else {
|
||||
// If the server can't filter then it's impossible to mute hashtags,
|
||||
// so disable the functionality.
|
||||
muteTagItem?.isVisible = false
|
||||
unmuteTagItem?.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -292,7 +299,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
),
|
||||
)
|
||||
|
||||
contentFiltersRepository.createContentFilter(newContentFilter)
|
||||
contentFiltersRepository.createContentFilter(intent.pachliAccountId, newContentFilter)
|
||||
.onSuccess {
|
||||
mutedContentFilter = it
|
||||
updateTagMuteState(true)
|
||||
|
@ -312,9 +319,9 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
val result = mutedContentFilter?.let { filter ->
|
||||
val newContexts = filter.contexts.filter { it != FilterContext.HOME }
|
||||
if (newContexts.isEmpty()) {
|
||||
contentFiltersRepository.deleteContentFilter(filter.id)
|
||||
contentFiltersRepository.deleteContentFilter(intent.pachliAccountId, filter.id)
|
||||
} else {
|
||||
contentFiltersRepository.updateContentFilter(filter, ContentFilterEdit(filter.id, contexts = newContexts))
|
||||
contentFiltersRepository.updateContentFilter(intent.pachliAccountId, filter, ContentFilterEdit(filter.id, contexts = newContexts))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -584,6 +584,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||
}
|
||||
|
||||
protected fun setupButtons(
|
||||
pachliAccountId: Long,
|
||||
viewData: T,
|
||||
listener: StatusActionListener<T>,
|
||||
accountId: String,
|
||||
|
@ -593,7 +594,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||
avatar.setOnClickListener(profileButtonClickListener)
|
||||
displayName.setOnClickListener(profileButtonClickListener)
|
||||
replyButton.setOnClickListener {
|
||||
listener.onReply(viewData)
|
||||
listener.onReply(pachliAccountId, viewData)
|
||||
}
|
||||
reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean ->
|
||||
// return true to play animation
|
||||
|
@ -744,6 +745,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
|
|||
listener,
|
||||
)
|
||||
setupButtons(
|
||||
pachliAccountId,
|
||||
viewData,
|
||||
listener,
|
||||
actionable.account.id,
|
||||
|
|
|
@ -90,8 +90,7 @@ open class StatusViewHolder<T : IStatusViewData>(
|
|||
protected fun setPollInfo(ownPoll: Boolean) = with(binding) {
|
||||
statusInfo.setText(if (ownPoll) R.string.poll_ended_created else R.string.poll_ended_voted)
|
||||
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
|
||||
statusInfo.compoundDrawablePadding =
|
||||
Utils.dpToPx(context, 10)
|
||||
statusInfo.compoundDrawablePadding = Utils.dpToPx(context, 10)
|
||||
statusInfo.setPaddingRelative(Utils.dpToPx(context, 28), 0, 0, 0)
|
||||
statusInfo.show()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package app.pachli.appstore
|
||||
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.Poll
|
||||
import app.pachli.core.network.model.Status
|
||||
|
@ -20,8 +19,6 @@ data class StatusComposedEvent(val status: Status) : Event
|
|||
data object StatusScheduledEvent : Event
|
||||
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
|
||||
data class ProfileEditedEvent(val newProfileData: Account) : Event
|
||||
data class MainTabsChangedEvent(val newTabs: List<Timeline>) : Event
|
||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
|
||||
data class DomainMuteEvent(val instance: String) : Event
|
||||
data class AnnouncementReadEvent(val announcementId: String) : Event
|
||||
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
|
||||
|
|
|
@ -968,7 +968,7 @@ class AccountActivity :
|
|||
kind = ComposeOptions.ComposeKind.NEW,
|
||||
)
|
||||
}
|
||||
val intent = ComposeActivityIntent(this, options)
|
||||
val intent = ComposeActivityIntent(this, intent.pachliAccountId, options)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.appstore.AnnouncementReadEvent
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.InstanceInfoRepository
|
||||
import app.pachli.core.network.model.Announcement
|
||||
import app.pachli.core.network.model.Emoji
|
||||
|
@ -31,6 +30,8 @@ import app.pachli.util.Loading
|
|||
import app.pachli.util.Resource
|
||||
import app.pachli.util.Success
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -38,9 +39,9 @@ import timber.log.Timber
|
|||
|
||||
@HiltViewModel
|
||||
class AnnouncementsViewModel @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val instanceInfoRepo: InstanceInfoRepository,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
) : ViewModel() {
|
||||
|
||||
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
|
||||
|
@ -59,29 +60,20 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
announcementsMutable.postValue(Loading())
|
||||
mastodonApi.listAnnouncements()
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.fold(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
},
|
||||
{ throwable ->
|
||||
Timber.d(
|
||||
"Failed to mark announcement as read.",
|
||||
throwable,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
},
|
||||
)
|
||||
.onSuccess {
|
||||
announcementsMutable.postValue(Success(it.body))
|
||||
it.body.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.onSuccess {
|
||||
accountManager.deleteAnnouncement(accountManager.activeAccount!!.id, announcement.id)
|
||||
}
|
||||
.onFailure { throwable ->
|
||||
Timber.d("Failed to mark announcement as read.", throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { announcementsMutable.postValue(Error(cause = it.throwable)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -87,6 +87,7 @@ import app.pachli.core.designsystem.R as DR
|
|||
import app.pachli.core.navigation.ComposeActivityIntent
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.InitialCursorPosition
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
import app.pachli.core.network.model.Attachment
|
||||
import app.pachli.core.network.model.Emoji
|
||||
import app.pachli.core.network.model.Status
|
||||
|
@ -121,6 +122,7 @@ import com.mikepenz.iconics.IconicsSize
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.withCreationCallback
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
|
@ -130,6 +132,7 @@ import kotlin.math.max
|
|||
import kotlin.math.min
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -153,16 +156,22 @@ class ComposeActivity :
|
|||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
/** The account that is being used to compose the status */
|
||||
private lateinit var activeAccount: AccountEntity
|
||||
|
||||
private var photoUploadUri: Uri? = null
|
||||
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
|
||||
@VisibleForTesting
|
||||
val viewModel: ComposeViewModel by viewModels()
|
||||
val viewModel: ComposeViewModel by viewModels(
|
||||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<ComposeViewModel.Factory> { factory ->
|
||||
factory.create(
|
||||
intent.pachliAccountId,
|
||||
ComposeActivityIntent.getComposeOptions(intent),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private val binding by viewBinding(ActivityComposeBinding::inflate)
|
||||
|
||||
|
@ -242,8 +251,6 @@ class ComposeActivity :
|
|||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
if (sharedPreferencesRepository.appTheme == AppTheme.BLACK) {
|
||||
setTheme(DR.style.AppDialogActivityBlackTheme)
|
||||
}
|
||||
|
@ -251,7 +258,8 @@ class ComposeActivity :
|
|||
|
||||
setupActionBar()
|
||||
|
||||
setupAvatar(activeAccount)
|
||||
val composeOptions: ComposeOptions? = ComposeActivityIntent.getComposeOptions(intent)
|
||||
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
|
@ -266,64 +274,71 @@ class ComposeActivity :
|
|||
onEditImage = this::editImageInQueue,
|
||||
onRemove = this::removeMediaFromQueue,
|
||||
)
|
||||
binding.composeMediaPreviewBar.layoutManager =
|
||||
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.composeMediaPreviewBar.adapter = mediaAdapter
|
||||
binding.composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
val composeOptions: ComposeOptions? = ComposeActivityIntent.getOptions(intent)
|
||||
viewModel.setup(composeOptions)
|
||||
lifecycleScope.launch {
|
||||
viewModel.accountFlow.distinctUntilChanged().collect { account ->
|
||||
setupAvatar(account.entity)
|
||||
|
||||
setupButtons()
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
if (viewModel.displaySelfUsername) {
|
||||
binding.composeUsernameView.text = getString(
|
||||
R.string.compose_active_account_description,
|
||||
account.entity.fullName,
|
||||
)
|
||||
binding.composeUsernameView.show()
|
||||
} else {
|
||||
binding.composeUsernameView.hide()
|
||||
}
|
||||
|
||||
if (accountManager.shouldDisplaySelfUsername()) {
|
||||
binding.composeUsernameView.text = getString(
|
||||
R.string.compose_active_account_description,
|
||||
activeAccount.fullName,
|
||||
)
|
||||
binding.composeUsernameView.show()
|
||||
} else {
|
||||
binding.composeUsernameView.hide()
|
||||
}
|
||||
viewModel.setup(account)
|
||||
|
||||
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
|
||||
val statusContent = composeOptions?.content
|
||||
if (!statusContent.isNullOrEmpty()) {
|
||||
binding.composeEditField.setText(statusContent)
|
||||
}
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, account.entity))
|
||||
|
||||
composeOptions?.scheduledAt?.let {
|
||||
binding.composeScheduleView.setDateTime(it)
|
||||
}
|
||||
setupButtons(account.id)
|
||||
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
|
||||
setupComposeField(sharedPreferencesRepository, viewModel.initialContent, composeOptions)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
applyShareIntent(intent, savedInstanceState)
|
||||
if (savedInstanceState != null) {
|
||||
setupComposeField(sharedPreferencesRepository, null, composeOptions)
|
||||
} else {
|
||||
setupComposeField(sharedPreferencesRepository, viewModel.initialContent, composeOptions)
|
||||
}
|
||||
|
||||
/* Finally, overwrite state with data from saved instance state. */
|
||||
savedInstanceState?.let {
|
||||
photoUploadUri = BundleCompat.getParcelable(it, KEY_PHOTO_UPLOAD_URI, Uri::class.java)
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
|
||||
(it.getSerializable(KEY_VISIBILITY) as Status.Visibility).apply {
|
||||
setStatusVisibility(this)
|
||||
binding.composeMediaPreviewBar.layoutManager =
|
||||
LinearLayoutManager(this@ComposeActivity, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.composeMediaPreviewBar.adapter = mediaAdapter
|
||||
binding.composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
setupReplyViews(viewModel.replyingStatusAuthor, viewModel.replyingStatusContent)
|
||||
|
||||
composeOptions?.scheduledAt?.let {
|
||||
binding.composeScheduleView.setDateTime(it)
|
||||
}
|
||||
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
applyShareIntent(intent, savedInstanceState)
|
||||
|
||||
/* Finally, overwrite state with data from saved instance state. */
|
||||
savedInstanceState?.let {
|
||||
photoUploadUri = BundleCompat.getParcelable(it, KEY_PHOTO_UPLOAD_URI, Uri::class.java)
|
||||
|
||||
(it.getSerializable(KEY_VISIBILITY) as Status.Visibility).apply {
|
||||
setStatusVisibility(this)
|
||||
}
|
||||
|
||||
it.getBoolean(KEY_CONTENT_WARNING_VISIBLE).apply {
|
||||
viewModel.showContentWarningChanged(this)
|
||||
}
|
||||
|
||||
(it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time ->
|
||||
viewModel.updateScheduledAt(time)
|
||||
}
|
||||
}
|
||||
|
||||
binding.composeEditField.post {
|
||||
binding.composeEditField.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
it.getBoolean(KEY_CONTENT_WARNING_VISIBLE).apply {
|
||||
viewModel.showContentWarningChanged(this)
|
||||
}
|
||||
|
||||
(it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time ->
|
||||
viewModel.updateScheduledAt(time)
|
||||
}
|
||||
}
|
||||
|
||||
binding.composeEditField.post {
|
||||
binding.composeEditField.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -372,8 +387,8 @@ class ComposeActivity :
|
|||
|
||||
private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) {
|
||||
if (replyingStatusAuthor != null) {
|
||||
binding.composeReplyView.show()
|
||||
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
binding.composeReplyView.show()
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 }
|
||||
|
||||
setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
|
@ -432,13 +447,15 @@ class ComposeActivity :
|
|||
viewModel.onContentChanged(editable)
|
||||
}
|
||||
|
||||
binding.composeEditField.setText(startingText)
|
||||
startingText?.let {
|
||||
binding.composeEditField.setText(it)
|
||||
|
||||
when (composeOptions?.initialCursorPosition ?: InitialCursorPosition.END) {
|
||||
InitialCursorPosition.START -> binding.composeEditField.setSelection(0)
|
||||
InitialCursorPosition.END -> binding.composeEditField.setSelection(
|
||||
binding.composeEditField.length(),
|
||||
)
|
||||
when (composeOptions?.initialCursorPosition ?: InitialCursorPosition.END) {
|
||||
InitialCursorPosition.START -> binding.composeEditField.setSelection(0)
|
||||
InitialCursorPosition.END -> binding.composeEditField.setSelection(
|
||||
binding.composeEditField.length(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
|
||||
|
@ -545,7 +562,7 @@ class ComposeActivity :
|
|||
bottomSheetStates.any { it != BottomSheetBehavior.STATE_HIDDEN }
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
private fun setupButtons(pachliAccountId: Long) {
|
||||
binding.composeOptionsBottomSheet.listener = this
|
||||
|
||||
composeOptionsBehavior = BottomSheetBehavior.from(binding.composeOptionsBottomSheet)
|
||||
|
@ -567,7 +584,7 @@ class ComposeActivity :
|
|||
enableButton(binding.composeEmojiButton, clickable = false, colorActive = false)
|
||||
|
||||
// Setup the interface buttons.
|
||||
binding.composeTootButton.setOnClickListener { onSendClicked() }
|
||||
binding.composeTootButton.setOnClickListener { onSendClicked(pachliAccountId) }
|
||||
binding.composeAddMediaButton.setOnClickListener { openPickDialog() }
|
||||
binding.composeToggleVisibilityButton.setOnClickListener { showComposeOptions() }
|
||||
binding.composeContentWarningButton.setOnClickListener { onContentWarningChanged() }
|
||||
|
@ -627,21 +644,21 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupAvatar(activeAccount: AccountEntity) {
|
||||
private fun setupAvatar(account: AccountEntity) {
|
||||
val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize)
|
||||
val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a ->
|
||||
a.getDimensionPixelSize(0, 1)
|
||||
}
|
||||
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
account.profilePictureUrl,
|
||||
binding.composeAvatar,
|
||||
avatarSize / 8,
|
||||
sharedPreferencesRepository.animateAvatars,
|
||||
)
|
||||
binding.composeAvatar.contentDescription = getString(
|
||||
R.string.compose_active_account_description,
|
||||
activeAccount.fullName,
|
||||
account.fullName,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -968,11 +985,11 @@ class ComposeActivity :
|
|||
return binding.composeScheduleView.verifyScheduledTime(viewModel.scheduledAt.value)
|
||||
}
|
||||
|
||||
private fun onSendClicked() = lifecycleScope.launch {
|
||||
private fun onSendClicked(pachliAccountId: Long) = lifecycleScope.launch {
|
||||
if (viewModel.confirmStatusLanguage) confirmStatusLanguage()
|
||||
|
||||
if (verifyScheduledTime()) {
|
||||
sendStatus()
|
||||
sendStatus(pachliAccountId)
|
||||
} else {
|
||||
showScheduleView()
|
||||
}
|
||||
|
@ -1074,7 +1091,7 @@ class ComposeActivity :
|
|||
return contentInfo
|
||||
}
|
||||
|
||||
private fun sendStatus() {
|
||||
private fun sendStatus(pachliAccountId: Long) {
|
||||
enableButtons(false, viewModel.editing)
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
var spoilerText = ""
|
||||
|
@ -1087,7 +1104,7 @@ class ComposeActivity :
|
|||
enableButtons(true, viewModel.editing)
|
||||
} else if (statusLength <= maximumTootCharacters) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.sendStatus(contentText, spoilerText, activeAccount.id)
|
||||
viewModel.sendStatus(contentText, spoilerText, pachliAccountId)
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
} else {
|
||||
|
@ -1200,7 +1217,16 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows/hides the content warning area depending on [show].
|
||||
*
|
||||
* Adjusts the colours of the content warning button to reflect the state.
|
||||
*/
|
||||
private fun showContentWarning(show: Boolean) {
|
||||
// Skip any animations if the current visibility matches the intended visibility. This
|
||||
// prevents a visual oddity where the compose editor animates in to view when first
|
||||
// opening the activity.
|
||||
if (binding.composeContentWarningBar.isVisible == show) return
|
||||
TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup)
|
||||
@ColorInt val color = if (show) {
|
||||
binding.composeContentWarningBar.show()
|
||||
|
@ -1230,7 +1256,7 @@ class ComposeActivity :
|
|||
if (event.isCtrlPressed) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
// send toot by pressing CTRL + ENTER
|
||||
this.onSendClicked()
|
||||
this.onSendClicked(intent.pachliAccountId)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -1382,6 +1408,7 @@ class ComposeActivity :
|
|||
|
||||
/** Media queued for upload. */
|
||||
data class QueuedMedia(
|
||||
val account: AccountEntity,
|
||||
val localId: Int,
|
||||
val uri: Uri,
|
||||
val type: Type,
|
||||
|
|
|
@ -36,7 +36,9 @@ import app.pachli.core.common.string.mastodonLength
|
|||
import app.pachli.core.common.string.randomAlphanumericString
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.InstanceInfoRepository
|
||||
import app.pachli.core.data.repository.PachliAccount
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.ServerOperation
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.ComposeKind
|
||||
|
@ -45,6 +47,7 @@ import app.pachli.core.network.model.NewPoll
|
|||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.preferences.ShowSelfUsername
|
||||
import app.pachli.core.ui.MentionSpan
|
||||
import app.pachli.service.MediaToSend
|
||||
import app.pachli.service.ServiceClient
|
||||
|
@ -60,23 +63,30 @@ import com.github.michaelbull.result.mapBoth
|
|||
import com.github.michaelbull.result.mapError
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class ComposeViewModel @Inject constructor(
|
||||
@HiltViewModel(assistedFactory = ComposeViewModel.Factory::class)
|
||||
class ComposeViewModel @AssistedInject constructor(
|
||||
@Assisted private val pachliAccountId: Long,
|
||||
@Assisted private val composeOptions: ComposeOptions?,
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
|
@ -86,13 +96,16 @@ class ComposeViewModel @Inject constructor(
|
|||
serverRepository: ServerRepository,
|
||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
) : ViewModel() {
|
||||
/** The account being used to compose the status. */
|
||||
val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId)
|
||||
.filterNotNull()
|
||||
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
|
||||
|
||||
private lateinit var pachliAccount: PachliAccount
|
||||
|
||||
/** The current content */
|
||||
private var content: Editable = Editable.Factory.getInstance().newEditable("")
|
||||
|
||||
/** The current content warning */
|
||||
private var contentWarning: String = ""
|
||||
|
||||
/**
|
||||
* The effective content warning. Either the real content warning, or the empty string
|
||||
* if the content warning has been hidden
|
||||
|
@ -100,39 +113,44 @@ class ComposeViewModel @Inject constructor(
|
|||
private val effectiveContentWarning
|
||||
get() = if (showContentWarning.value) contentWarning else ""
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
val replyingStatusAuthor: String? = composeOptions?.replyingStatusAuthor
|
||||
val replyingStatusContent: String? = composeOptions?.replyingStatusContent
|
||||
|
||||
/** The initial content for this status, before any edits */
|
||||
internal var initialContent: String = ""
|
||||
internal var initialContent: String = composeOptions?.content.orEmpty()
|
||||
|
||||
/** The initial content warning for this status, before any edits */
|
||||
private var initialContentWarning: String = ""
|
||||
private val initialContentWarning: String = composeOptions?.contentWarning.orEmpty()
|
||||
|
||||
/** The current content warning */
|
||||
private var contentWarning: String = initialContentWarning
|
||||
|
||||
/** The initial language for this status, before any changes */
|
||||
private var initialLanguage: String? = null
|
||||
private val initialLanguage: String? = composeOptions?.language
|
||||
|
||||
/** The current language for this status. */
|
||||
internal var language: String? = null
|
||||
internal var language: String? = initialLanguage
|
||||
|
||||
/** If editing a draft then the ID of the draft, otherwise 0 */
|
||||
private var draftId: Int = 0
|
||||
private var scheduledTootId: String? = null
|
||||
private var inReplyToId: String? = null
|
||||
private var originalStatusId: String? = null
|
||||
private val draftId = composeOptions?.draftId ?: 0
|
||||
private val scheduledTootId: String? = composeOptions?.scheduledTootId
|
||||
private val inReplyToId: String? = composeOptions?.inReplyToId
|
||||
private val originalStatusId: String? = composeOptions?.statusId
|
||||
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
|
||||
|
||||
private var contentWarningStateChanged: Boolean = false
|
||||
private var modifiedInitialState: Boolean = false
|
||||
private val modifiedInitialState: Boolean = composeOptions?.modifiedInitialState == true
|
||||
private var scheduledTimeChanged: Boolean = false
|
||||
|
||||
val instanceInfo = instanceInfoRepo.instanceInfo
|
||||
|
||||
val emojis = instanceInfoRepo.emojis
|
||||
|
||||
private val _markMediaAsSensitive: MutableStateFlow<Boolean> =
|
||||
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow()
|
||||
private val _markMediaAsSensitive: MutableStateFlow<Boolean?> = MutableStateFlow(composeOptions?.sensitive)
|
||||
val markMediaAsSensitive = accountFlow.combine(_markMediaAsSensitive) { account, sens ->
|
||||
sens ?: account.entity.defaultMediaSensitivity
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
private val _statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
|
||||
val statusVisibility = _statusVisibility.asStateFlow()
|
||||
|
@ -140,7 +158,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val showContentWarning = _showContentWarning.asStateFlow()
|
||||
private val _poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
|
||||
val poll = _poll.asStateFlow()
|
||||
private val _scheduledAt: MutableStateFlow<Date?> = MutableStateFlow(null)
|
||||
private val _scheduledAt: MutableStateFlow<Date?> = MutableStateFlow(composeOptions?.scheduledAt)
|
||||
val scheduledAt = _scheduledAt.asStateFlow()
|
||||
|
||||
private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
|
@ -166,11 +184,19 @@ class ComposeViewModel @Inject constructor(
|
|||
sharedPreferencesRepository.confirmStatusLanguage = value
|
||||
}
|
||||
|
||||
private lateinit var composeKind: ComposeKind
|
||||
private val composeKind = composeOptions?.kind ?: ComposeKind.NEW
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
||||
// TODO: Copied from MainViewModel. Probably belongs back in AccountManager
|
||||
val displaySelfUsername: Boolean
|
||||
get() = when (sharedPreferencesRepository.showSelfUsername) {
|
||||
ShowSelfUsername.ALWAYS -> true
|
||||
ShowSelfUsername.DISAMBIGUATE -> accountManager.accountsFlow.value.size > 1
|
||||
ShowSelfUsername.NEVER -> false
|
||||
}
|
||||
|
||||
private var setupComplete = false
|
||||
|
||||
/** Errors preparing media for upload. */
|
||||
|
@ -220,6 +246,7 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
_media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
account = pachliAccount.entity,
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
type = type,
|
||||
|
@ -253,9 +280,10 @@ class ComposeViewModel @Inject constructor(
|
|||
return mediaItem
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||
private fun addUploadedMedia(account: AccountEntity, id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||
_media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
account = account,
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
type = type,
|
||||
|
@ -393,11 +421,11 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
draftHelper.saveDraft(
|
||||
draftId = draftId,
|
||||
pachliAccountId = accountManager.activeAccount?.id!!,
|
||||
pachliAccountId = pachliAccountId,
|
||||
inReplyToId = inReplyToId,
|
||||
content = content,
|
||||
contentWarning = contentWarning,
|
||||
sensitive = _markMediaAsSensitive.value,
|
||||
sensitive = markMediaAsSensitive.value,
|
||||
visibility = statusVisibility.value,
|
||||
mediaUris = mediaUris,
|
||||
mediaDescriptions = mediaDescriptions,
|
||||
|
@ -438,7 +466,7 @@ class ComposeViewModel @Inject constructor(
|
|||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value.serverString(),
|
||||
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || showContentWarning.value),
|
||||
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
|
||||
media = attachedMedia,
|
||||
scheduledAt = scheduledAt.value,
|
||||
inReplyToId = inReplyToId,
|
||||
|
@ -552,30 +580,22 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun setup(composeOptions: ComposeOptions?) {
|
||||
fun setup(account: PachliAccount) {
|
||||
if (setupComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
composeKind = composeOptions?.kind ?: ComposeKind.NEW
|
||||
pachliAccount = account
|
||||
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
val preferredVisibility = account.entity.defaultPostPrivacy
|
||||
|
||||
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
|
||||
startingVisibility = Status.Visibility.getOrUnknown(
|
||||
preferredVisibility.ordinal.coerceAtLeast(replyVisibility.ordinal),
|
||||
)
|
||||
|
||||
inReplyToId = composeOptions?.inReplyToId
|
||||
|
||||
modifiedInitialState = composeOptions?.modifiedInitialState == true
|
||||
|
||||
val contentWarning = composeOptions?.contentWarning
|
||||
if (contentWarning != null) {
|
||||
initialContentWarning = contentWarning
|
||||
}
|
||||
if (!contentWarningStateChanged) {
|
||||
_showContentWarning.value = !contentWarning.isNullOrBlank()
|
||||
_showContentWarning.value = contentWarning.isNotBlank()
|
||||
}
|
||||
|
||||
// recreate media list
|
||||
|
@ -595,17 +615,10 @@ class ComposeViewModel @Inject constructor(
|
|||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
|
||||
}
|
||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
|
||||
addUploadedMedia(account.entity, a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
|
||||
}
|
||||
}
|
||||
|
||||
draftId = composeOptions?.draftId ?: 0
|
||||
scheduledTootId = composeOptions?.scheduledTootId
|
||||
originalStatusId = composeOptions?.statusId
|
||||
initialContent = composeOptions?.content ?: ""
|
||||
initialLanguage = composeOptions?.language
|
||||
language = initialLanguage
|
||||
|
||||
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
|
||||
if (tootVisibility != Status.Visibility.UNKNOWN) {
|
||||
startingVisibility = tootVisibility
|
||||
|
@ -622,16 +635,10 @@ class ComposeViewModel @Inject constructor(
|
|||
initialContent = builder.toString()
|
||||
}
|
||||
|
||||
_scheduledAt.value = composeOptions?.scheduledAt
|
||||
|
||||
composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it }
|
||||
|
||||
val poll = composeOptions?.poll
|
||||
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
|
||||
if (poll != null && composeOptions?.mediaAttachments.isNullOrEmpty()) {
|
||||
_poll.value = poll
|
||||
}
|
||||
replyingStatusContent = composeOptions?.replyingStatusContent
|
||||
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
|
||||
|
||||
updateCloseConfirmation()
|
||||
setupComplete = true
|
||||
|
@ -715,4 +722,16 @@ class ComposeViewModel @Inject constructor(
|
|||
return length
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
/**
|
||||
* Creates [ComposeViewModel] with [pachliAccountId] as the active account and
|
||||
* active [composeOptions].
|
||||
*/
|
||||
fun create(
|
||||
pachliAccountId: Long,
|
||||
composeOptions: ComposeOptions?,
|
||||
): ComposeViewModel
|
||||
}
|
||||
}
|
||||
|
|
|
@ -435,7 +435,13 @@ class MediaUploader @Inject constructor(
|
|||
MultipartBody.Part.createFormData("focus", "${it.x},${it.y}")
|
||||
}
|
||||
|
||||
val uploadResult = mediaUploadApi.uploadMedia(body, description, focus)
|
||||
val uploadResult = mediaUploadApi.uploadMediaWithAuth(
|
||||
media.account.authHeader,
|
||||
media.account.domain,
|
||||
body,
|
||||
description,
|
||||
focus,
|
||||
)
|
||||
.mapEither(
|
||||
{
|
||||
if (it.code == 200) {
|
||||
|
|
|
@ -33,7 +33,7 @@ fun showAddPollDialog(
|
|||
maxOptionCount: Int,
|
||||
maxOptionLength: Int,
|
||||
minDuration: Int,
|
||||
maxDuration: Int,
|
||||
maxDuration: Long,
|
||||
onUpdatePoll: (NewPoll) -> Unit,
|
||||
) {
|
||||
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
|
||||
|
|
|
@ -83,6 +83,7 @@ class ConversationViewHolder internal constructor(
|
|||
hideSensitiveMediaWarning()
|
||||
}
|
||||
setupButtons(
|
||||
pachliAccountId,
|
||||
viewData,
|
||||
listener,
|
||||
account.id,
|
||||
|
|
|
@ -348,8 +348,8 @@ class ConversationsFragment :
|
|||
// not needed
|
||||
}
|
||||
|
||||
override fun onReply(viewData: ConversationViewData) {
|
||||
reply(viewData.lastStatus.actionable)
|
||||
override fun onReply(pachliAccountId: Long, viewData: ConversationViewData) {
|
||||
reply(pachliAccountId, viewData.lastStatus.actionable)
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List<Int>) {
|
||||
|
|
|
@ -29,6 +29,7 @@ import app.pachli.core.common.extensions.visible
|
|||
import app.pachli.core.database.model.DraftEntity
|
||||
import app.pachli.core.navigation.ComposeActivityIntent
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
import app.pachli.core.network.parseAsMastodonHtml
|
||||
import app.pachli.core.ui.BackgroundMessage
|
||||
import app.pachli.databinding.ActivityDraftsBinding
|
||||
|
@ -126,7 +127,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
startActivity(ComposeActivityIntent(context, composeOptions))
|
||||
startActivity(ComposeActivityIntent(context, intent.pachliAccountId, composeOptions))
|
||||
},
|
||||
{ throwable ->
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
@ -162,7 +163,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
kind = ComposeOptions.ComposeKind.EDIT_DRAFT,
|
||||
)
|
||||
|
||||
startActivity(ComposeActivityIntent(this, composeOptions))
|
||||
startActivity(ComposeActivityIntent(this, intent.pachliAccountId, composeOptions))
|
||||
}
|
||||
|
||||
override fun onDeleteDraft(draft: DraftEntity) {
|
||||
|
|
|
@ -3,7 +3,9 @@ package app.pachli.components.filters
|
|||
import android.content.DialogInterface.BUTTON_POSITIVE
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import app.pachli.R
|
||||
import app.pachli.core.activity.BaseActivity
|
||||
import app.pachli.core.activity.extensions.TransitionKind
|
||||
|
@ -11,7 +13,6 @@ import app.pachli.core.activity.extensions.startActivityWithTransition
|
|||
import app.pachli.core.common.extensions.hide
|
||||
import app.pachli.core.common.extensions.show
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.extensions.visible
|
||||
import app.pachli.core.model.ContentFilter
|
||||
import app.pachli.core.navigation.EditContentFilterActivityIntent
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
|
@ -19,13 +20,20 @@ import app.pachli.core.ui.BackgroundMessage
|
|||
import app.pachli.databinding.ActivityContentFiltersBinding
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.withCreationCallback
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ContentFiltersActivity : BaseActivity(), ContentFiltersListener {
|
||||
|
||||
private val binding by viewBinding(ActivityContentFiltersBinding::inflate)
|
||||
private val viewModel: ContentFiltersViewModel by viewModels()
|
||||
private val viewModel: ContentFiltersViewModel by viewModels(
|
||||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<ContentFiltersViewModel.Factory> { factory ->
|
||||
factory.create(intent.pachliAccountId)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -42,58 +50,39 @@ class ContentFiltersActivity : BaseActivity(), ContentFiltersListener {
|
|||
launchEditContentFilterActivity()
|
||||
}
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() }
|
||||
binding.swipeRefreshLayout.setOnRefreshListener {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
viewModel.refreshContentFilters()
|
||||
}
|
||||
|
||||
binding.swipeRefreshLayout.setColorSchemeColors(MaterialColors.getColor(binding.root, androidx.appcompat.R.attr.colorPrimary))
|
||||
binding.includedToolbar.appbar.setLiftOnScrollTargetView(binding.filtersList)
|
||||
|
||||
setTitle(R.string.pref_title_content_filters)
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
loadFilters()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
private fun bind() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
binding.progressBar.visible(state.loadingState == ContentFiltersViewModel.LoadingState.LOADING)
|
||||
binding.swipeRefreshLayout.isRefreshing = state.loadingState == ContentFiltersViewModel.LoadingState.LOADING
|
||||
binding.addFilterButton.visible(state.loadingState == ContentFiltersViewModel.LoadingState.LOADED)
|
||||
|
||||
when (state.loadingState) {
|
||||
ContentFiltersViewModel.LoadingState.INITIAL, ContentFiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
|
||||
ContentFiltersViewModel.LoadingState.ERROR_NETWORK -> {
|
||||
binding.messageView.setup(BackgroundMessage.Network()) {
|
||||
loadFilters()
|
||||
}
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
viewModel.contentFilters.collect { contentFilters ->
|
||||
binding.filtersList.adapter = FiltersAdapter(this@ContentFiltersActivity, contentFilters.contentFilters)
|
||||
if (contentFilters.contentFilters.isEmpty()) {
|
||||
binding.messageView.setup(BackgroundMessage.Empty())
|
||||
binding.messageView.show()
|
||||
}
|
||||
|
||||
ContentFiltersViewModel.LoadingState.ERROR_OTHER -> {
|
||||
binding.messageView.setup(BackgroundMessage.GenericError()) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
|
||||
ContentFiltersViewModel.LoadingState.LOADED -> {
|
||||
binding.filtersList.adapter = FiltersAdapter(this@ContentFiltersActivity, state.contentFilters)
|
||||
if (state.contentFilters.isEmpty()) {
|
||||
binding.messageView.setup(BackgroundMessage.Empty())
|
||||
binding.messageView.show()
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
viewModel.load()
|
||||
lifecycleScope.launch {
|
||||
viewModel.operationCount.collectLatest {
|
||||
if (it == 0) binding.progressIndicator.hide() else binding.progressIndicator.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchEditContentFilterActivity(contentFilter: ContentFilter? = null) {
|
||||
|
|
|
@ -3,64 +3,66 @@ package app.pachli.components.filters
|
|||
import android.view.View
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.ContentFilters
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.model.ContentFilter
|
||||
import app.pachli.core.model.ContentFilterVersion
|
||||
import app.pachli.core.ui.OperationCounter
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class ContentFiltersViewModel @Inject constructor(
|
||||
@HiltViewModel(assistedFactory = ContentFiltersViewModel.Factory::class)
|
||||
class ContentFiltersViewModel @AssistedInject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val contentFiltersRepository: ContentFiltersRepository,
|
||||
@Assisted val pachliAccountId: Long,
|
||||
) : ViewModel() {
|
||||
|
||||
enum class LoadingState {
|
||||
INITIAL,
|
||||
LOADING,
|
||||
LOADED,
|
||||
ERROR_NETWORK,
|
||||
ERROR_OTHER,
|
||||
val contentFilters = flow {
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).filterNotNull()
|
||||
.distinctUntilChangedBy { it.contentFilters }
|
||||
.collect { emit(it.contentFilters) }
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
ContentFilters(contentFilters = emptyList(), version = ContentFilterVersion.V1),
|
||||
)
|
||||
|
||||
data class State(val contentFilters: List<ContentFilter>, val loadingState: LoadingState)
|
||||
private val operationCounter = OperationCounter()
|
||||
val operationCount = operationCounter.count
|
||||
|
||||
val state: Flow<State> get() = _state
|
||||
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
|
||||
|
||||
fun load() {
|
||||
this@ContentFiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
|
||||
|
||||
viewModelScope.launch {
|
||||
contentFiltersRepository.contentFilters.collect { result ->
|
||||
result.onSuccess { filters ->
|
||||
this@ContentFiltersViewModel._state.update { State(filters?.contentFilters.orEmpty(), LoadingState.LOADED) }
|
||||
}
|
||||
.onFailure {
|
||||
// TODO: There's an ERROR_NETWORK state to maybe consider here. Or get rid of
|
||||
// that and do proper error handling.
|
||||
this@ContentFiltersViewModel._state.update {
|
||||
it.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun refreshContentFilters() = viewModelScope.launch {
|
||||
operationCounter {
|
||||
contentFiltersRepository.refresh(pachliAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContentFilter(contentFilter: ContentFilter, parent: View) {
|
||||
viewModelScope.launch {
|
||||
contentFiltersRepository.deleteContentFilter(contentFilter.id)
|
||||
.onSuccess {
|
||||
this@ContentFiltersViewModel._state.value = State(this@ContentFiltersViewModel._state.value.contentFilters.filter { it.id != contentFilter.id }, LoadingState.LOADED)
|
||||
}
|
||||
.onFailure {
|
||||
Snackbar.make(parent, "Error deleting filter '${contentFilter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
operationCounter {
|
||||
contentFiltersRepository.deleteContentFilter(pachliAccountId, contentFilter.id)
|
||||
.onFailure {
|
||||
Snackbar.make(parent, "Error deleting filter '${contentFilter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
/** Creates [ContentFiltersViewModel] with [pachliAccountId] as the active account. */
|
||||
fun create(pachliAccountId: Long): ContentFiltersViewModel
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import app.pachli.core.model.FilterAction
|
|||
import app.pachli.core.model.FilterContext
|
||||
import app.pachli.core.model.FilterKeyword
|
||||
import app.pachli.core.navigation.EditContentFilterActivityIntent
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
import app.pachli.core.ui.extensions.await
|
||||
import app.pachli.databinding.ActivityEditContentFilterBinding
|
||||
import app.pachli.databinding.DialogFilterBinding
|
||||
|
@ -55,6 +56,7 @@ class EditContentFilterActivity : BaseActivity() {
|
|||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<EditContentFilterViewModel.Factory> { factory ->
|
||||
factory.create(
|
||||
intent.pachliAccountId,
|
||||
EditContentFilterActivityIntent.getContentFilter(intent),
|
||||
EditContentFilterActivityIntent.getContentFilterId(intent),
|
||||
)
|
||||
|
|
|
@ -37,7 +37,6 @@ import com.github.michaelbull.result.Ok
|
|||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.map
|
||||
import com.github.michaelbull.result.mapEither
|
||||
import com.github.michaelbull.result.mapError
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
|
@ -205,12 +204,14 @@ enum class UiMode {
|
|||
* is initialised, and [uiMode] is [UiMode.CREATE].
|
||||
*
|
||||
* @param contentFiltersRepository
|
||||
* @param pachliAccountId ID of the account owning the filters
|
||||
* @param contentFilter Filter to show
|
||||
* @param contentFilterId ID of filter to fetch and show
|
||||
*/
|
||||
@HiltViewModel(assistedFactory = EditContentFilterViewModel.Factory::class)
|
||||
class EditContentFilterViewModel @AssistedInject constructor(
|
||||
val contentFiltersRepository: ContentFiltersRepository,
|
||||
private val contentFiltersRepository: ContentFiltersRepository,
|
||||
@Assisted val pachliAccountId: Long,
|
||||
@Assisted val contentFilter: ContentFilter?,
|
||||
@Assisted val contentFilterId: String?,
|
||||
) : ViewModel() {
|
||||
|
@ -242,13 +243,8 @@ class EditContentFilterViewModel @AssistedInject constructor(
|
|||
|
||||
emit(
|
||||
contentFilterId?.let {
|
||||
contentFiltersRepository.getContentFilter(contentFilterId)
|
||||
.onSuccess {
|
||||
originalContentFilter = it
|
||||
}.mapEither(
|
||||
{ ContentFilterViewData.from(it) },
|
||||
{ UiError.GetContentFilterError(contentFilterId, it) },
|
||||
)
|
||||
originalContentFilter = contentFiltersRepository.getContentFilter(pachliAccountId, contentFilterId)
|
||||
originalContentFilter?.let { Ok(ContentFilterViewData.from(it)) } ?: Ok(ContentFilterViewData())
|
||||
} ?: Ok(ContentFilterViewData()),
|
||||
)
|
||||
}.onEach { it.onSuccess { it?.let { onChange(it) } } }
|
||||
|
@ -262,13 +258,12 @@ class EditContentFilterViewModel @AssistedInject constructor(
|
|||
fun reload() = viewModelScope.launch {
|
||||
contentFilterId ?: return@launch _contentFilterViewData.emit(Ok(ContentFilterViewData()))
|
||||
|
||||
originalContentFilter = contentFiltersRepository.getContentFilter(pachliAccountId, contentFilterId)
|
||||
|
||||
_contentFilterViewData.emit(
|
||||
contentFiltersRepository.getContentFilter(contentFilterId)
|
||||
.onSuccess { originalContentFilter = it }
|
||||
.mapEither(
|
||||
{ ContentFilterViewData.from(it) },
|
||||
{ UiError.GetContentFilterError(contentFilterId, it) },
|
||||
),
|
||||
originalContentFilter?.let {
|
||||
Ok(ContentFilterViewData.from(it))
|
||||
} ?: Ok(ContentFilterViewData()),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -384,13 +379,13 @@ class EditContentFilterViewModel @AssistedInject constructor(
|
|||
|
||||
/** Create a new filter from [contentFilterViewData]. */
|
||||
private suspend fun createContentFilter(contentFilterViewData: ContentFilterViewData): Result<ContentFilter, UiError> {
|
||||
return contentFiltersRepository.createContentFilter(NewContentFilter.from(contentFilterViewData))
|
||||
return contentFiltersRepository.createContentFilter(pachliAccountId, NewContentFilter.from(contentFilterViewData))
|
||||
.mapError { UiError.SaveContentFilterError(it) }
|
||||
}
|
||||
|
||||
/** Persists the changes to [contentFilterViewData]. */
|
||||
private suspend fun updateContentFilter(contentFilterViewData: ContentFilterViewData): Result<ContentFilter, UiError> {
|
||||
return contentFiltersRepository.updateContentFilter(originalContentFilter!!, contentFilterViewData.diff(originalContentFilter!!))
|
||||
return contentFiltersRepository.updateContentFilter(pachliAccountId, originalContentFilter!!, contentFilterViewData.diff(originalContentFilter!!))
|
||||
.mapError { UiError.SaveContentFilterError(it) }
|
||||
}
|
||||
|
||||
|
@ -399,7 +394,7 @@ class EditContentFilterViewModel @AssistedInject constructor(
|
|||
val filterViewData = contentFilterViewData.value.get() ?: return@launch
|
||||
|
||||
// TODO: Check for non-null, or have a type that makes this impossible.
|
||||
contentFiltersRepository.deleteContentFilter(filterViewData.id!!)
|
||||
contentFiltersRepository.deleteContentFilter(pachliAccountId, filterViewData.id!!)
|
||||
.onSuccess { _uiResult.send(Ok(UiSuccess.DeleteFilter)) }
|
||||
.onFailure { _uiResult.send(Err(UiError.DeleteContentFilterError(it))) }
|
||||
}
|
||||
|
@ -407,12 +402,16 @@ class EditContentFilterViewModel @AssistedInject constructor(
|
|||
@AssistedFactory
|
||||
interface Factory {
|
||||
/**
|
||||
* Creates [EditContentFilterViewModel], passing optional [contentFilter] and
|
||||
* Creates [EditContentFilterViewModel] for [pachliAccountId], passing optional [contentFilter] and
|
||||
* [contentFilterId] parameters.
|
||||
*
|
||||
* @see EditContentFilterViewModel
|
||||
*/
|
||||
fun create(contentFilter: ContentFilter?, contentFilterId: String?): EditContentFilterViewModel
|
||||
fun create(
|
||||
pachliAccountId: Long,
|
||||
contentFilter: ContentFilter?,
|
||||
contentFilterId: String?,
|
||||
): EditContentFilterViewModel
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -61,7 +61,7 @@ class NotificationFetcher @Inject constructor(
|
|||
|
||||
val accounts = buildList {
|
||||
if (pachliAccountId == NotificationWorker.ALL_ACCOUNTS) {
|
||||
addAll(accountManager.getAllAccountsOrderedByActive())
|
||||
addAll(accountManager.accountsOrderedByActive)
|
||||
} else {
|
||||
accountManager.getAccountById(pachliAccountId)?.let { add(it) }
|
||||
}
|
||||
|
@ -131,8 +131,6 @@ class NotificationFetcher @Inject constructor(
|
|||
notificationManager,
|
||||
account,
|
||||
)
|
||||
|
||||
accountManager.saveAccount(account)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error while fetching notifications")
|
||||
}
|
||||
|
@ -160,7 +158,6 @@ class NotificationFetcher @Inject constructor(
|
|||
*/
|
||||
private suspend fun fetchNewNotifications(account: AccountEntity): List<Notification> {
|
||||
Timber.d("fetchNewNotifications(%s)", account.fullName)
|
||||
val authHeader = "Bearer ${account.accessToken}"
|
||||
|
||||
// Figure out where to read from. Choose the most recent notification ID from:
|
||||
//
|
||||
|
@ -168,7 +165,7 @@ class NotificationFetcher @Inject constructor(
|
|||
// - account.notificationMarkerId
|
||||
// - account.lastNotificationId
|
||||
Timber.d("getting notification marker for %s", account.fullName)
|
||||
val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
|
||||
val remoteMarkerId = fetchMarker(account)?.lastReadId ?: "0"
|
||||
val localMarkerId = account.notificationMarkerId
|
||||
val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId
|
||||
val readingPosition = account.lastNotificationId
|
||||
|
@ -186,7 +183,7 @@ class NotificationFetcher @Inject constructor(
|
|||
val now = Instant.now()
|
||||
Timber.d("Fetching notifications from server")
|
||||
mastodonApi.notificationsWithAuth(
|
||||
authHeader,
|
||||
account.authHeader,
|
||||
account.domain,
|
||||
minId = minId,
|
||||
).onSuccess { response ->
|
||||
|
@ -222,21 +219,20 @@ class NotificationFetcher @Inject constructor(
|
|||
val newMarkerId = notifications.first().id
|
||||
Timber.d("updating notification marker for %s to: %s", account.fullName, newMarkerId)
|
||||
mastodonApi.updateMarkersWithAuth(
|
||||
auth = authHeader,
|
||||
auth = account.authHeader,
|
||||
domain = account.domain,
|
||||
notificationsLastReadId = newMarkerId,
|
||||
)
|
||||
account.notificationMarkerId = newMarkerId
|
||||
accountManager.saveAccount(account)
|
||||
accountManager.setNotificationMarkerId(account.id, newMarkerId)
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
|
||||
private suspend fun fetchMarker(account: AccountEntity): Marker? {
|
||||
return try {
|
||||
val allMarkers = mastodonApi.markersWithAuth(
|
||||
authHeader,
|
||||
account.authHeader,
|
||||
account.domain,
|
||||
listOf("notifications"),
|
||||
)
|
||||
|
|
|
@ -114,8 +114,8 @@ private const val EXTRA_NOTIFICATION_TYPE =
|
|||
* Takes a given Mastodon notification and creates a new Android notification or updates the
|
||||
* existing Android notification.
|
||||
*
|
||||
* The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
|
||||
* to the ID of the account that received the notification.
|
||||
* The Android notification tag is the Mastodon notification ID, and the notification ID
|
||||
* is the ID of the account that received the notification.
|
||||
*
|
||||
* @param context to access application preferences and services
|
||||
* @param mastodonNotification a new Mastodon notification
|
||||
|
@ -129,8 +129,7 @@ fun makeNotification(
|
|||
account: AccountEntity,
|
||||
isFirstOfBatch: Boolean,
|
||||
): android.app.Notification {
|
||||
var notif = mastodonNotification
|
||||
notif = notif.rewriteToStatusTypeIfNeeded(account.accountId)
|
||||
val notif = mastodonNotification.rewriteToStatusTypeIfNeeded(account.accountId)
|
||||
val mastodonNotificationId = notif.id
|
||||
val accountId = account.id.toInt()
|
||||
|
||||
|
@ -146,7 +145,7 @@ fun makeNotification(
|
|||
// Create the notification -- either create a new one, or use the existing one.
|
||||
val builder = existingAndroidNotification?.let {
|
||||
NotificationCompat.Builder(context, it)
|
||||
} ?: newAndroidNotification(context, notif, account)
|
||||
} ?: newAndroidNotification(context, notificationId, notif, account)
|
||||
|
||||
builder
|
||||
.setContentTitle(titleForType(context, notif, account))
|
||||
|
@ -292,10 +291,12 @@ fun updateSummaryNotifications(
|
|||
|
||||
// All notifications in this group have the same type, so get it from the first.
|
||||
val notificationType = members[0].notification.extras.getEnum<Notification.Type>(EXTRA_NOTIFICATION_TYPE)
|
||||
val summaryResultIntent = MainActivityIntent.openNotification(
|
||||
val summaryResultIntent = MainActivityIntent.fromNotification(
|
||||
context,
|
||||
accountId.toLong(),
|
||||
notificationType,
|
||||
-1,
|
||||
null,
|
||||
type = notificationType,
|
||||
)
|
||||
val summaryStackBuilder = TaskStackBuilder.create(context)
|
||||
summaryStackBuilder.addParentStack(MainActivity::class.java)
|
||||
|
@ -344,10 +345,17 @@ fun updateSummaryNotifications(
|
|||
|
||||
private fun newAndroidNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
body: Notification,
|
||||
account: AccountEntity,
|
||||
): NotificationCompat.Builder {
|
||||
val eventResultIntent = MainActivityIntent.openNotification(context, account.id, body.type)
|
||||
val eventResultIntent = MainActivityIntent.fromNotification(
|
||||
context,
|
||||
account.id,
|
||||
notificationId,
|
||||
body.id,
|
||||
body.type,
|
||||
)
|
||||
val eventStackBuilder = TaskStackBuilder.create(context)
|
||||
eventStackBuilder.addParentStack(MainActivity::class.java)
|
||||
eventStackBuilder.addNextIntent(eventResultIntent)
|
||||
|
@ -432,12 +440,12 @@ private fun getStatusComposeIntent(
|
|||
language = language,
|
||||
kind = ComposeOptions.ComposeKind.NEW,
|
||||
)
|
||||
val composeIntent = MainActivityIntent.openCompose(
|
||||
val composeIntent = MainActivityIntent.fromNotificationCompose(
|
||||
context,
|
||||
composeOptions,
|
||||
account.id,
|
||||
body.id,
|
||||
composeOptions,
|
||||
account.id.toInt(),
|
||||
body.id,
|
||||
)
|
||||
return PendingIntent.getActivity(
|
||||
context.applicationContext,
|
||||
|
@ -597,8 +605,8 @@ fun disablePullNotifications(context: Context) {
|
|||
NotificationConfig.notificationMethod = NotificationConfig.Method.Unknown
|
||||
}
|
||||
|
||||
fun clearNotificationsForAccount(context: Context, account: AccountEntity) {
|
||||
val accountId = account.id.toInt()
|
||||
fun clearNotificationsForAccount(context: Context, pachliAccountId: Long) {
|
||||
val accountId = pachliAccountId.toInt()
|
||||
val notificationManager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
for (androidNotification in notificationManager.activeNotifications) {
|
||||
|
|
|
@ -82,6 +82,7 @@ import com.google.android.material.snackbar.Snackbar
|
|||
import com.mikepenz.iconics.IconicsSize
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.withCreationCallback
|
||||
import kotlin.properties.Delegates
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
@ -104,7 +105,13 @@ class NotificationsFragment :
|
|||
MenuProvider,
|
||||
ReselectableFragment {
|
||||
|
||||
private val viewModel: NotificationsViewModel by viewModels()
|
||||
private val viewModel: NotificationsViewModel by viewModels(
|
||||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<NotificationsViewModel.Factory> { factory ->
|
||||
factory.create(requireArguments().getLong(ARG_PACHLI_ACCOUNT_ID))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
||||
|
||||
|
@ -116,6 +123,16 @@ class NotificationsFragment :
|
|||
|
||||
override var pachliAccountId by Delegates.notNull<Long>()
|
||||
|
||||
// Update post timestamps
|
||||
private val updateTimestampFlow = flow {
|
||||
while (true) {
|
||||
delay(60000)
|
||||
emit(Unit)
|
||||
}
|
||||
}.onEach {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -124,10 +141,9 @@ class NotificationsFragment :
|
|||
adapter = NotificationsPagingAdapter(
|
||||
notificationDiffCallback,
|
||||
pachliAccountId,
|
||||
accountId = viewModel.account.accountId,
|
||||
statusActionListener = this,
|
||||
notificationActionListener = this,
|
||||
accountActionListener = this,
|
||||
statusActionListener = this@NotificationsFragment,
|
||||
notificationActionListener = this@NotificationsFragment,
|
||||
accountActionListener = this@NotificationsFragment,
|
||||
statusDisplayOptions = viewModel.statusDisplayOptions.value,
|
||||
)
|
||||
}
|
||||
|
@ -181,7 +197,7 @@ class NotificationsFragment :
|
|||
// reading position is always restorable.
|
||||
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -211,16 +227,6 @@ class NotificationsFragment :
|
|||
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
|
||||
false
|
||||
|
||||
// Update post timestamps
|
||||
val updateTimestampFlow = flow {
|
||||
while (true) {
|
||||
delay(60000)
|
||||
emit(Unit)
|
||||
}
|
||||
}.onEach {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
|
@ -317,10 +323,13 @@ class NotificationsFragment :
|
|||
val status = when (it) {
|
||||
is StatusActionSuccess.Bookmark ->
|
||||
statusViewData.status.copy(bookmarked = it.action.state)
|
||||
|
||||
is StatusActionSuccess.Favourite ->
|
||||
statusViewData.status.copy(favourited = it.action.state)
|
||||
|
||||
is StatusActionSuccess.Reblog ->
|
||||
statusViewData.status.copy(reblogged = it.action.state)
|
||||
|
||||
is StatusActionSuccess.VoteInPoll ->
|
||||
statusViewData.status.copy(
|
||||
poll = it.action.poll.votedCopy(it.action.choices),
|
||||
|
@ -340,6 +349,7 @@ class NotificationsFragment :
|
|||
when (it) {
|
||||
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation ->
|
||||
adapter.refresh()
|
||||
|
||||
else -> {
|
||||
/* nothing to do */
|
||||
}
|
||||
|
@ -413,7 +423,10 @@ class NotificationsFragment :
|
|||
}
|
||||
peeked = true
|
||||
}
|
||||
else -> { /* nothing to do */ }
|
||||
|
||||
else -> {
|
||||
/* nothing to do */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -430,11 +443,15 @@ class NotificationsFragment :
|
|||
binding.progressBar.show()
|
||||
}
|
||||
}
|
||||
|
||||
UserRefreshState.COMPLETE, UserRefreshState.ERROR -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
else -> { /* nothing to do */ }
|
||||
|
||||
else -> {
|
||||
/* nothing to do */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -499,7 +516,7 @@ class NotificationsFragment :
|
|||
override fun onRefresh() {
|
||||
binding.progressBar.isVisible = false
|
||||
adapter.refresh()
|
||||
clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
clearNotificationsForAccount(requireContext(), pachliAccountId)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
@ -509,7 +526,7 @@ class NotificationsFragment :
|
|||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
if (position >= 0) {
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -524,11 +541,11 @@ class NotificationsFragment :
|
|||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
|
||||
clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
clearNotificationsForAccount(requireContext(), pachliAccountId)
|
||||
}
|
||||
|
||||
override fun onReply(viewData: NotificationViewData) {
|
||||
super.reply(viewData.statusViewData!!.actionable)
|
||||
override fun onReply(pachliAccountId: Long, viewData: NotificationViewData) {
|
||||
super.reply(pachliAccountId, viewData.statusViewData!!.actionable)
|
||||
}
|
||||
|
||||
override fun onReblog(viewData: NotificationViewData, reblog: Boolean) {
|
||||
|
@ -634,7 +651,7 @@ class NotificationsFragment :
|
|||
private fun showFilterDialog() {
|
||||
FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter ->
|
||||
if (viewModel.uiState.value.activeFilter != filter) {
|
||||
viewModel.accept(InfallibleUiAction.ApplyFilter(filter))
|
||||
viewModel.accept(InfallibleUiAction.ApplyFilter(pachliAccountId, filter))
|
||||
}
|
||||
}.show(parentFragmentManager, "dialogFilter")
|
||||
}
|
||||
|
|
|
@ -112,8 +112,6 @@ interface NotificationActionListener {
|
|||
class NotificationsPagingAdapter(
|
||||
diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
|
||||
private val pachliAccountId: Long,
|
||||
/** ID of the the account that notifications are being displayed for */
|
||||
private val accountId: String,
|
||||
private val statusActionListener: StatusActionListener<NotificationViewData>,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
|
@ -150,14 +148,12 @@ class NotificationsPagingAdapter(
|
|||
StatusViewHolder(
|
||||
ItemStatusBinding.inflate(inflater, parent, false),
|
||||
statusActionListener,
|
||||
accountId,
|
||||
)
|
||||
}
|
||||
NotificationViewKind.STATUS_FILTERED -> {
|
||||
FilterableStatusViewHolder(
|
||||
ItemStatusWrapperBinding.inflate(inflater, parent, false),
|
||||
statusActionListener,
|
||||
accountId,
|
||||
)
|
||||
}
|
||||
NotificationViewKind.NOTIFICATION -> {
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
package app.pachli.components.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -32,8 +31,8 @@ import app.pachli.appstore.MuteConversationEvent
|
|||
import app.pachli.appstore.MuteEvent
|
||||
import app.pachli.core.common.extensions.throttleFirst
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.ContentFilterVersion
|
||||
import app.pachli.core.model.FilterAction
|
||||
import app.pachli.core.model.FilterContext
|
||||
|
@ -49,11 +48,10 @@ import app.pachli.util.serialize
|
|||
import app.pachli.viewdata.NotificationViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -64,14 +62,16 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
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.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
@ -119,7 +119,10 @@ sealed interface InfallibleUiAction : UiAction {
|
|||
// 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
|
||||
data class ApplyFilter(
|
||||
val pachliAccountId: Long,
|
||||
val filter: Set<Notification.Type>,
|
||||
) : InfallibleUiAction
|
||||
|
||||
/**
|
||||
* User is leaving the fragment, save the ID of the visible notification.
|
||||
|
@ -127,7 +130,10 @@ sealed interface InfallibleUiAction : UiAction {
|
|||
* 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
|
||||
data class SaveVisibleId(
|
||||
val pachliAccountId: Long,
|
||||
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
|
||||
|
@ -306,21 +312,23 @@ sealed interface UiError {
|
|||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
// TODO: Context is required because handling filter errors needs to
|
||||
// format a resource string. As soon as that is removed this can be removed.
|
||||
@ApplicationContext private val context: Context,
|
||||
@HiltViewModel(assistedFactory = NotificationsViewModel.Factory::class)
|
||||
class NotificationsViewModel @AssistedInject constructor(
|
||||
private val repository: NotificationsRepository,
|
||||
private val accountManager: AccountManager,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val eventHub: EventHub,
|
||||
private val contentFiltersRepository: ContentFiltersRepository,
|
||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
@Assisted val pachliAccountId: Long,
|
||||
) : ViewModel() {
|
||||
val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId)
|
||||
.filterNotNull()
|
||||
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
|
||||
|
||||
/** The account to display notifications for */
|
||||
val account = accountManager.activeAccount!!
|
||||
val account: AccountEntity
|
||||
get() = accountFlow.replayCache.first().entity
|
||||
|
||||
val uiState: StateFlow<UiState>
|
||||
|
||||
|
@ -362,23 +370,17 @@ class NotificationsViewModel @Inject constructor(
|
|||
|
||||
init {
|
||||
// Handle changes to notification filters
|
||||
val notificationFilter = uiAction
|
||||
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
|
||||
.distinctUntilChanged()
|
||||
// Save each change back to the active account
|
||||
.onEach { action ->
|
||||
Timber.d("notificationFilter: %s", 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),
|
||||
),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
uiAction
|
||||
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { action ->
|
||||
accountManager.setNotificationsFilter(
|
||||
action.pachliAccountId,
|
||||
serialize(action.filter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the last notification ID to "0" to fetch the newest notifications, and
|
||||
// increment `reload` to trigger creation of a new PagingSource.
|
||||
|
@ -386,8 +388,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
uiAction
|
||||
.filterIsInstance<InfallibleUiAction.LoadNewest>()
|
||||
.collectLatest {
|
||||
account.lastNotificationId = "0"
|
||||
accountManager.saveAccount(account)
|
||||
accountManager.setLastNotificationId(account.id, "0")
|
||||
reload.getAndUpdate { it + 1 }
|
||||
repository.invalidate()
|
||||
}
|
||||
|
@ -400,8 +401,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
.distinctUntilChanged()
|
||||
.collectLatest { action ->
|
||||
Timber.d("Saving visible ID: %s, active account = %d", action.visibleId, account.id)
|
||||
account.lastNotificationId = action.visibleId
|
||||
accountManager.saveAccount(account)
|
||||
accountManager.setLastNotificationId(account.id, action.visibleId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -480,18 +480,14 @@ class NotificationsViewModel @Inject constructor(
|
|||
|
||||
// Fetch the status filters
|
||||
viewModelScope.launch {
|
||||
contentFiltersRepository.contentFilters.collect { filters ->
|
||||
filters.onSuccess {
|
||||
contentFilterModel = when (it?.version) {
|
||||
accountManager.activePachliAccountFlow
|
||||
.distinctUntilChangedBy { it.contentFilters }
|
||||
.collect { account ->
|
||||
contentFilterModel = when (account.contentFilters.version) {
|
||||
ContentFilterVersion.V2 -> ContentFilterModel(FilterContext.NOTIFICATIONS)
|
||||
ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.NOTIFICATIONS, it.contentFilters)
|
||||
else -> null
|
||||
ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.NOTIFICATIONS, account.contentFilters.contentFilters)
|
||||
}
|
||||
reload.getAndUpdate { it + 1 }
|
||||
}.onFailure {
|
||||
_uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle events that should refresh the list
|
||||
|
@ -505,16 +501,16 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
|
||||
// Re-fetch notifications if either of `notificationsFilter` or `reload` flows have
|
||||
// new items.
|
||||
pagingData = combine(notificationFilter, reload) { action, _ -> action }
|
||||
.flatMapLatest { action ->
|
||||
getNotifications(filters = action.filter, initialKey = getInitialKey())
|
||||
pagingData = combine(accountFlow.distinctUntilChangedBy { it.entity.notificationsFilter }, reload) { account, _ -> account }
|
||||
.flatMapLatest { account ->
|
||||
getNotifications(account.entity.accountId, filters = deserialize(account.entity.notificationsFilter), initialKey = getInitialKey())
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
uiState = combine(notificationFilter, getUiPrefs()) { filter, _ ->
|
||||
uiState = combine(accountFlow.distinctUntilChangedBy { it.entity.notificationsFilter }, getUiPrefs()) { account, _ ->
|
||||
UiState(
|
||||
activeFilter = filter.filter,
|
||||
activeFilter = deserialize(account.entity.notificationsFilter),
|
||||
showFabWhileScrolling = !sharedPreferencesRepository.getBoolean(PrefKeys.FAB_HIDE, false),
|
||||
tabTapBehaviour = sharedPreferencesRepository.tabTapBehaviour,
|
||||
)
|
||||
|
@ -526,6 +522,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun getNotifications(
|
||||
accountId: String,
|
||||
filters: Set<Notification.Type>,
|
||||
initialKey: String? = null,
|
||||
): Flow<PagingData<NotificationViewData>> {
|
||||
|
@ -541,6 +538,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
isExpanded = statusDisplayOptions.value.openSpoiler,
|
||||
isCollapsed = true,
|
||||
filterAction = filterAction,
|
||||
isAboutSelf = notification.account.id == accountId,
|
||||
)
|
||||
}.filter {
|
||||
it.statusViewData?.filterAction != FilterAction.HIDE
|
||||
|
@ -565,4 +563,10 @@ class NotificationsViewModel @Inject constructor(
|
|||
private fun getUiPrefs() = sharedPreferencesRepository.changes
|
||||
.filter { UiPrefs.prefKeys.contains(it) }
|
||||
.onStart { emit(null) }
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
/** Creates [NotificationsViewModel] with [pachliAccountId] as the active account. */
|
||||
fun create(pachliAccountId: Long): NotificationsViewModel
|
||||
}
|
||||
}
|
||||
|
|
|
@ -208,12 +208,7 @@ suspend fun disablePushNotificationsForAccount(context: Context, api: MastodonAp
|
|||
if (account.notificationMethod != AccountNotificationMethod.PUSH) return
|
||||
|
||||
// Clear the push notification from the account.
|
||||
account.unifiedPushUrl = ""
|
||||
account.pushServerKey = ""
|
||||
account.pushAuth = ""
|
||||
account.pushPrivKey = ""
|
||||
account.pushPubKey = ""
|
||||
accountManager.saveAccount(account)
|
||||
accountManager.clearPushNotificationData(account.id)
|
||||
NotificationConfig.notificationMethodAccount[account.fullName] = NotificationConfig.Method.Pull
|
||||
|
||||
// Try and unregister the endpoint from the server. Nothing we can do if this fails, and no
|
||||
|
@ -273,7 +268,7 @@ suspend fun registerUnifiedPushEndpoint(
|
|||
val auth = CryptoUtil.secureRandomBytesEncoded(16)
|
||||
|
||||
api.subscribePushNotifications(
|
||||
"Bearer ${account.accessToken}",
|
||||
account.authHeader,
|
||||
account.domain,
|
||||
endpoint,
|
||||
keyPair.pubkey,
|
||||
|
@ -286,12 +281,14 @@ suspend fun registerUnifiedPushEndpoint(
|
|||
}.onSuccess {
|
||||
Timber.d("UnifiedPush registration succeeded for account %d", account.id)
|
||||
|
||||
account.pushPubKey = keyPair.pubkey
|
||||
account.pushPrivKey = keyPair.privKey
|
||||
account.pushAuth = auth
|
||||
account.pushServerKey = it.body.serverKey
|
||||
account.unifiedPushUrl = endpoint
|
||||
accountManager.saveAccount(account)
|
||||
accountManager.setPushNotificationData(
|
||||
account.id,
|
||||
unifiedPushUrl = endpoint,
|
||||
pushServerKey = it.body.serverKey,
|
||||
pushAuth = auth,
|
||||
pushPrivKey = keyPair.privKey,
|
||||
pushPubKey = keyPair.pubkey,
|
||||
)
|
||||
|
||||
NotificationConfig.notificationMethodAccount[account.fullName] = NotificationConfig.Method.Push
|
||||
}
|
||||
|
|
|
@ -29,7 +29,6 @@ import app.pachli.viewdata.NotificationViewData
|
|||
internal class StatusViewHolder(
|
||||
binding: ItemStatusBinding,
|
||||
private val statusActionListener: StatusActionListener<NotificationViewData>,
|
||||
private val accountId: String,
|
||||
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder<NotificationViewData>(binding) {
|
||||
|
||||
override fun bind(
|
||||
|
@ -56,7 +55,7 @@ internal class StatusViewHolder(
|
|||
)
|
||||
}
|
||||
if (viewData.type == Notification.Type.POLL) {
|
||||
setPollInfo(accountId == viewData.account.id)
|
||||
setPollInfo(viewData.isAboutSelf)
|
||||
} else {
|
||||
hideStatusInfo()
|
||||
}
|
||||
|
@ -66,7 +65,6 @@ internal class StatusViewHolder(
|
|||
class FilterableStatusViewHolder(
|
||||
binding: ItemStatusWrapperBinding,
|
||||
private val statusActionListener: StatusActionListener<NotificationViewData>,
|
||||
private val accountId: String,
|
||||
) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder<NotificationViewData>(binding) {
|
||||
// Note: Identical to bind() in StatusViewHolder above
|
||||
override fun bind(
|
||||
|
@ -93,7 +91,7 @@ class FilterableStatusViewHolder(
|
|||
)
|
||||
}
|
||||
if (viewData.type == Notification.Type.POLL) {
|
||||
setPollInfo(accountId == viewData.account.id)
|
||||
setPollInfo(viewData.isAboutSelf)
|
||||
} else {
|
||||
hideStatusInfo()
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ import app.pachli.core.common.util.unsafeLazy
|
|||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.AccountPreferenceDataStore
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.canFilterV1
|
||||
import app.pachli.core.data.repository.canFilterV2
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import app.pachli.core.navigation.AccountListActivityIntent
|
||||
import app.pachli.core.navigation.ContentFiltersActivityIntent
|
||||
|
@ -60,13 +62,13 @@ import app.pachli.util.getInitialLanguages
|
|||
import app.pachli.util.getLocaleList
|
||||
import app.pachli.util.getPachliDisplayName
|
||||
import app.pachli.util.iconRes
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlin.properties.Delegates
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
|
@ -110,12 +112,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
// Enable/disable the filter preference based on info from
|
||||
// FiltersRespository. filterPreferences is safe to access here,
|
||||
// the server. filterPreferences is safe to access here,
|
||||
// it was populated in onCreatePreferences, called by onCreate
|
||||
// before onViewCreated is called.
|
||||
contentFiltersRepository.contentFilters.collect { filters ->
|
||||
filterPreference.isEnabled = filters is Ok
|
||||
}
|
||||
accountManager.activePachliAccountFlow
|
||||
.distinctUntilChangedBy { it.server }
|
||||
.collect { account ->
|
||||
filterPreference.isEnabled = account.server.canFilterV2() || account.server.canFilterV1()
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onViewCreated(view, savedInstanceState)
|
||||
|
@ -188,7 +192,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
setTitle(R.string.title_migration_relogin)
|
||||
setIcon(R.drawable.ic_logout)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = LoginActivityIntent(context, LoginMode.MIGRATION)
|
||||
val intent = LoginActivityIntent(context, LoginMode.Reauthenticate(accountManager.activeAccount!!.domain))
|
||||
activity?.startActivityWithTransition(intent, TransitionKind.EXPLODE)
|
||||
true
|
||||
}
|
||||
|
@ -326,11 +330,13 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
val account = response.body()
|
||||
if (response.isSuccessful && account != null) {
|
||||
accountManager.activeAccount?.let {
|
||||
it.defaultPostPrivacy = account.source?.privacy
|
||||
?: Status.Visibility.PUBLIC
|
||||
it.defaultMediaSensitivity = account.source?.sensitive ?: false
|
||||
it.defaultPostLanguage = language.orEmpty()
|
||||
accountManager.saveAccount(it)
|
||||
accountManager.setDefaultPostPrivacy(
|
||||
it.id,
|
||||
account.source?.privacy
|
||||
?: Status.Visibility.PUBLIC,
|
||||
)
|
||||
accountManager.setDefaultMediaSensitivity(it.id, account.source?.sensitive ?: false)
|
||||
accountManager.setDefaultPostLanguage(it.id, language.orEmpty())
|
||||
}
|
||||
} else {
|
||||
Timber.e("failed updating settings on server")
|
||||
|
|
|
@ -23,7 +23,6 @@ import app.pachli.components.notifications.disablePullNotifications
|
|||
import app.pachli.components.notifications.domain.AndroidNotificationsAreEnabledUseCase
|
||||
import app.pachli.components.notifications.enablePullNotifications
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.preferences.PrefKeys
|
||||
import app.pachli.settings.makePreferenceScreen
|
||||
import app.pachli.settings.preferenceCategory
|
||||
|
@ -33,7 +32,6 @@ import javax.inject.Inject
|
|||
|
||||
@AndroidEntryPoint
|
||||
class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
|
@ -50,7 +48,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsEnabled
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsEnabled = newValue as Boolean }
|
||||
accountManager.setNotificationsEnabled(activeAccount.id, newValue as Boolean)
|
||||
if (androidNotificationsAreEnabled(context)) {
|
||||
enablePullNotifications(context)
|
||||
} else {
|
||||
|
@ -70,7 +68,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsFollowed
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsFollowed = newValue as Boolean }
|
||||
accountManager.setNotificationsFollowed(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -81,7 +79,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsFollowRequested
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsFollowRequested = newValue as Boolean }
|
||||
accountManager.setNotificationsFollowRequested(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -92,7 +90,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsReblogged
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsReblogged = newValue as Boolean }
|
||||
accountManager.setNotificationsReblogged(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -103,7 +101,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsFavorited
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsFavorited = newValue as Boolean }
|
||||
accountManager.setNotificationsFavorited(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -114,7 +112,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsPolls
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsPolls = newValue as Boolean }
|
||||
accountManager.setNotificationsPolls(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -125,7 +123,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsSubscriptions
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsSubscriptions = newValue as Boolean }
|
||||
accountManager.setNotificationsSubscriptions(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -136,7 +134,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsSignUps
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsSignUps = newValue as Boolean }
|
||||
accountManager.setNotificationsSignUps(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -147,7 +145,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsUpdates
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsUpdates = newValue as Boolean }
|
||||
accountManager.setNotificationsUpdates(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -158,7 +156,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsReports
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsReports = newValue as Boolean }
|
||||
accountManager.setNotificationsReports(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -174,7 +172,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationSound
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationSound = newValue as Boolean }
|
||||
accountManager.setNotificationSound(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -185,7 +183,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationVibration
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationVibration = newValue as Boolean }
|
||||
accountManager.setNotificationVibration(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -196,7 +194,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationLight
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationLight = newValue as Boolean }
|
||||
accountManager.setNotificationLight(activeAccount.id, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -204,13 +202,6 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
private inline fun updateAccount(changer: (AccountEntity) -> Unit) {
|
||||
accountManager.activeAccount?.let { account ->
|
||||
changer(account)
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().setTitle(R.string.pref_title_edit_notification_settings)
|
||||
|
|
|
@ -79,20 +79,22 @@ class PreferencesActivity :
|
|||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
val preferenceType = PreferencesActivityIntent.getPreferenceType(intent)
|
||||
if (savedInstanceState == null) {
|
||||
val preferenceType = PreferencesActivityIntent.getPreferenceType(intent)
|
||||
|
||||
val fragmentTag = "preference_fragment_$preferenceType"
|
||||
val fragmentTag = "preference_fragment_$preferenceType"
|
||||
|
||||
val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag)
|
||||
?: when (preferenceType) {
|
||||
PreferenceScreen.GENERAL -> PreferencesFragment.newInstance()
|
||||
PreferenceScreen.ACCOUNT -> AccountPreferencesFragment.newInstance(intent.pachliAccountId)
|
||||
PreferenceScreen.NOTIFICATION -> NotificationPreferencesFragment.newInstance()
|
||||
else -> throw IllegalArgumentException("preferenceType not known")
|
||||
val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag)
|
||||
?: when (preferenceType) {
|
||||
PreferenceScreen.GENERAL -> PreferencesFragment.newInstance()
|
||||
PreferenceScreen.ACCOUNT -> AccountPreferencesFragment.newInstance(intent.pachliAccountId)
|
||||
PreferenceScreen.NOTIFICATION -> NotificationPreferencesFragment.newInstance()
|
||||
else -> throw IllegalArgumentException("preferenceType not known")
|
||||
}
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, fragment, fragmentTag)
|
||||
}
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, fragment, fragmentTag)
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
|
||||
|
@ -148,6 +150,7 @@ class PreferencesActivity :
|
|||
TransitionKind.SLIDE_FROM_END.closeExit,
|
||||
)
|
||||
replace(R.id.fragment_container, fragment)
|
||||
setReorderingAllowed(true)
|
||||
addToBackStack(null)
|
||||
}
|
||||
return true
|
||||
|
|
|
@ -35,6 +35,7 @@ import app.pachli.core.common.extensions.show
|
|||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.navigation.ComposeActivityIntent
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
import app.pachli.core.network.model.ScheduledStatus
|
||||
import app.pachli.core.ui.BackgroundMessage
|
||||
import app.pachli.databinding.ActivityScheduledStatusBinding
|
||||
|
@ -155,6 +156,7 @@ class ScheduledStatusActivity :
|
|||
override fun edit(item: ScheduledStatus) {
|
||||
val intent = ComposeActivityIntent(
|
||||
this,
|
||||
intent.pachliAccountId,
|
||||
ComposeOptions(
|
||||
scheduledTootId = item.id,
|
||||
content = item.params.text,
|
||||
|
|
|
@ -74,6 +74,7 @@ import app.pachli.core.common.extensions.show
|
|||
import app.pachli.core.common.extensions.toggleVisibility
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.extensions.visible
|
||||
import app.pachli.core.data.model.Server
|
||||
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE
|
||||
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_FROM
|
||||
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO
|
||||
|
@ -89,7 +90,6 @@ import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_RE
|
|||
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE
|
||||
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE
|
||||
import app.pachli.core.navigation.pachliAccountId
|
||||
import app.pachli.core.network.Server
|
||||
import app.pachli.core.ui.extensions.await
|
||||
import app.pachli.core.ui.extensions.awaitSingleChoiceItem
|
||||
import app.pachli.core.ui.extensions.reduceSwipeSensitivity
|
||||
|
|
|
@ -34,6 +34,7 @@ import app.pachli.components.search.SearchOperator.LanguageOperator
|
|||
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||
import app.pachli.components.search.adapter.SearchPagingSourceFactory
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.Loadable
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE
|
||||
|
@ -70,6 +71,7 @@ import kotlinx.coroutines.async
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
@ -120,13 +122,14 @@ class SearchViewModel @Inject constructor(
|
|||
*/
|
||||
val operatorViewData = _operatorViewData.asStateFlow()
|
||||
|
||||
val locales = accountManager.activeAccountFlow.map {
|
||||
getLocaleList(getInitialLanguages(activeAccount = it))
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
getLocaleList(getInitialLanguages()),
|
||||
)
|
||||
val locales = accountManager.activeAccountFlow
|
||||
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||
.map { getLocaleList(getInitialLanguages(activeAccount = it.data)) }
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
getLocaleList(getInitialLanguages()),
|
||||
)
|
||||
|
||||
val server = serverRepository.flow.stateIn(
|
||||
viewModelScope,
|
||||
|
|
|
@ -92,8 +92,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
viewModel.contentHiddenChange(viewData, isShowing)
|
||||
}
|
||||
|
||||
override fun onReply(viewData: StatusViewData) {
|
||||
reply(viewData)
|
||||
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
|
||||
reply(pachliAccountId, viewData)
|
||||
}
|
||||
|
||||
override fun onFavourite(viewData: StatusViewData, favourite: Boolean) {
|
||||
|
@ -173,7 +173,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
)
|
||||
}
|
||||
|
||||
private fun reply(status: StatusViewData) {
|
||||
private fun reply(pachliAccountId: Long, status: StatusViewData) {
|
||||
val actionableStatus = status.actionable
|
||||
val mentionedUsernames = actionableStatus.mentions.map { it.username }
|
||||
.toMutableSet()
|
||||
|
@ -184,6 +184,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
|
||||
val intent = ComposeActivityIntent(
|
||||
requireContext(),
|
||||
pachliAccountId,
|
||||
ComposeOptions(
|
||||
inReplyToId = status.actionableId,
|
||||
replyVisibility = actionableStatus.visibility,
|
||||
|
@ -321,11 +322,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_delete_and_redraft -> {
|
||||
showConfirmEditDialog(statusViewData)
|
||||
showConfirmEditDialog(pachliAccountId, statusViewData)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_edit -> {
|
||||
editStatus(id, status)
|
||||
editStatus(pachliAccountId, id, status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.pin -> {
|
||||
|
@ -414,7 +415,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
}
|
||||
|
||||
// TODO: Identical to the same function in SFragment.kt
|
||||
private fun showConfirmEditDialog(statusViewData: StatusViewData) {
|
||||
private fun showConfirmEditDialog(pachliAccountId: Long, statusViewData: StatusViewData) {
|
||||
activity?.let {
|
||||
AlertDialog.Builder(it)
|
||||
.setMessage(R.string.dialog_redraft_post_warning)
|
||||
|
@ -432,6 +433,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
|
||||
val intent = ComposeActivityIntent(
|
||||
requireContext(),
|
||||
pachliAccountId,
|
||||
ComposeOptions(
|
||||
content = redraftStatus.text.orEmpty(),
|
||||
inReplyToId = redraftStatus.inReplyToId,
|
||||
|
@ -458,7 +460,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
}
|
||||
}
|
||||
|
||||
private fun editStatus(id: String, status: Status) {
|
||||
private fun editStatus(pachliAccountId: Long, id: String, status: Status) {
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.statusSource(id).fold(
|
||||
{ source ->
|
||||
|
@ -474,7 +476,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
|
|||
poll = status.poll?.toNewPoll(status.createdAt),
|
||||
kind = ComposeOptions.ComposeKind.EDIT_POSTED,
|
||||
)
|
||||
startActivity(ComposeActivityIntent(requireContext(), composeOptions))
|
||||
startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions))
|
||||
},
|
||||
{
|
||||
Snackbar.make(
|
||||
|
|
|
@ -24,11 +24,11 @@ import androidx.paging.PagingConfig
|
|||
import androidx.paging.PagingData
|
||||
import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator
|
||||
import app.pachli.core.common.di.ApplicationScope
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.database.dao.RemoteKeyDao
|
||||
import app.pachli.core.database.dao.TimelineDao
|
||||
import app.pachli.core.database.dao.TranslatedStatusDao
|
||||
import app.pachli.core.database.di.TransactionProvider
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.database.model.StatusViewDataEntity
|
||||
import app.pachli.core.database.model.TimelineStatusWithAccount
|
||||
import app.pachli.core.database.model.TranslatedStatusEntity
|
||||
|
@ -36,7 +36,6 @@ import app.pachli.core.database.model.TranslationState
|
|||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Translation
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.util.EmptyPagingSource
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
@ -46,7 +45,6 @@ import javax.inject.Singleton
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -61,7 +59,6 @@ import timber.log.Timber
|
|||
@Singleton
|
||||
class CachedTimelineRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val transactionProvider: TransactionProvider,
|
||||
val timelineDao: TimelineDao,
|
||||
private val remoteKeyDao: RemoteKeyDao,
|
||||
|
@ -71,58 +68,51 @@ class CachedTimelineRepository @Inject constructor(
|
|||
) {
|
||||
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
|
||||
|
||||
private var activeAccount = accountManager.activeAccount
|
||||
|
||||
/** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */
|
||||
@OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class)
|
||||
fun getStatusStream(
|
||||
account: AccountEntity,
|
||||
kind: Timeline,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
initialKey: String? = null,
|
||||
): Flow<PagingData<TimelineStatusWithAccount>> {
|
||||
Timber.d("getStatusStream(): key: %s", initialKey)
|
||||
|
||||
return accountManager.activeAccountFlow.flatMapLatest {
|
||||
activeAccount = it
|
||||
Timber.d("getStatusStream, account is %s", account.fullName)
|
||||
|
||||
factory = InvalidatingPagingSourceFactory {
|
||||
activeAccount?.let { timelineDao.getStatuses(it.id) } ?: EmptyPagingSource()
|
||||
}
|
||||
factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) }
|
||||
|
||||
val row = initialKey?.let { key ->
|
||||
// Room is row-keyed (by Int), not item-keyed, so the status ID string that was
|
||||
// passed as `initialKey` won't work.
|
||||
//
|
||||
// Instead, get all the status IDs for this account, in timeline order, and find the
|
||||
// row index that contains the status. The row index is the correct initialKey.
|
||||
activeAccount?.let { account ->
|
||||
timelineDao.getStatusRowNumber(account.id)
|
||||
.indexOfFirst { it == key }.takeIf { it != -1 }
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("initialKey: %s is row: %d", initialKey, row)
|
||||
|
||||
Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = pageSize,
|
||||
jumpThreshold = PAGE_SIZE * 3,
|
||||
enablePlaceholders = true,
|
||||
),
|
||||
initialKey = row,
|
||||
remoteMediator = CachedTimelineRemoteMediator(
|
||||
initialKey,
|
||||
mastodonApi,
|
||||
activeAccount!!.id,
|
||||
factory!!,
|
||||
transactionProvider,
|
||||
timelineDao,
|
||||
remoteKeyDao,
|
||||
moshi,
|
||||
),
|
||||
pagingSourceFactory = factory!!,
|
||||
).flow
|
||||
val row = initialKey?.let { key ->
|
||||
// Room is row-keyed (by Int), not item-keyed, so the status ID string that was
|
||||
// passed as `initialKey` won't work.
|
||||
//
|
||||
// Instead, get all the status IDs for this account, in timeline order, and find the
|
||||
// row index that contains the status. The row index is the correct initialKey.
|
||||
timelineDao.getStatusRowNumber(account.id)
|
||||
.indexOfFirst { it == key }.takeIf { it != -1 }
|
||||
}
|
||||
|
||||
Timber.d("initialKey: %s is row: %d", initialKey, row)
|
||||
|
||||
return Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = pageSize,
|
||||
jumpThreshold = PAGE_SIZE * 3,
|
||||
enablePlaceholders = true,
|
||||
),
|
||||
initialKey = row,
|
||||
remoteMediator = CachedTimelineRemoteMediator(
|
||||
initialKey,
|
||||
mastodonApi,
|
||||
account.id,
|
||||
factory!!,
|
||||
transactionProvider,
|
||||
timelineDao,
|
||||
remoteKeyDao,
|
||||
moshi,
|
||||
),
|
||||
pagingSourceFactory = factory!!,
|
||||
).flow
|
||||
}
|
||||
|
||||
/** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */
|
||||
|
|
|
@ -26,13 +26,12 @@ import androidx.paging.PagingSource
|
|||
import app.pachli.components.timeline.viewmodel.NetworkTimelinePagingSource
|
||||
import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator
|
||||
import app.pachli.components.timeline.viewmodel.PageCache
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.ui.getDomain
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -71,16 +70,18 @@ import timber.log.Timber
|
|||
/** Timeline repository where the timeline information is backed by an in-memory cache. */
|
||||
class NetworkTimelineRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
) {
|
||||
private val pageCache = PageCache()
|
||||
|
||||
private var factory: InvalidatingPagingSourceFactory<String, Status>? = null
|
||||
|
||||
// TODO: This should use assisted injection, and inject the account.
|
||||
private var activeAccount: AccountEntity? = null
|
||||
|
||||
/** @return flow of Mastodon [Status], loaded in [pageSize] increments */
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
fun getStatusStream(
|
||||
viewModelScope: CoroutineScope,
|
||||
account: AccountEntity,
|
||||
kind: Timeline,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
initialKey: String? = null,
|
||||
|
@ -94,9 +95,8 @@ class NetworkTimelineRepository @Inject constructor(
|
|||
return Pager(
|
||||
config = PagingConfig(pageSize = pageSize),
|
||||
remoteMediator = NetworkTimelineRemoteMediator(
|
||||
viewModelScope,
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
account,
|
||||
factory!!,
|
||||
pageCache,
|
||||
kind,
|
||||
|
|
|
@ -630,8 +630,8 @@ class TimelineFragment :
|
|||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onReply(viewData: StatusViewData) {
|
||||
super.reply(viewData.actionable)
|
||||
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
|
||||
super.reply(pachliAccountId, viewData.actionable)
|
||||
}
|
||||
|
||||
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
package app.pachli.components.timeline.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
|
@ -31,8 +30,8 @@ import app.pachli.appstore.PinEvent
|
|||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.FilterAction
|
||||
import app.pachli.core.network.model.Poll
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
|
@ -40,7 +39,6 @@ import app.pachli.usecase.TimelineCases
|
|||
import app.pachli.viewdata.StatusViewData
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -55,43 +53,39 @@ import timber.log.Timber
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class CachedTimelineViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: CachedTimelineRepository,
|
||||
timelineCases: TimelineCases,
|
||||
eventHub: EventHub,
|
||||
contentFiltersRepository: ContentFiltersRepository,
|
||||
accountManager: AccountManager,
|
||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
private val moshi: Moshi,
|
||||
) : TimelineViewModel(
|
||||
context,
|
||||
savedStateHandle,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
contentFiltersRepository,
|
||||
accountManager,
|
||||
statusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository,
|
||||
) {
|
||||
|
||||
override var statuses: Flow<PagingData<StatusViewData>>
|
||||
|
||||
init {
|
||||
readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId
|
||||
|
||||
statuses = reload.flatMapLatest {
|
||||
getStatuses(initialKey = getInitialKey())
|
||||
statuses = refreshFlow.flatMapLatest {
|
||||
getStatuses(it.second, initialKey = getInitialKey())
|
||||
}.cachedIn(viewModelScope)
|
||||
}
|
||||
|
||||
/** @return Flow of statuses that make up the timeline of [timeline] */
|
||||
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
|
||||
private fun getStatuses(
|
||||
account: AccountEntity,
|
||||
initialKey: String? = null,
|
||||
): Flow<PagingData<StatusViewData>> {
|
||||
Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey)
|
||||
return repository.getStatusStream(kind = timeline, initialKey = initialKey)
|
||||
return repository.getStatusStream(account, kind = timeline, initialKey = initialKey)
|
||||
.map { pagingData ->
|
||||
pagingData
|
||||
.map {
|
||||
|
|
|
@ -23,12 +23,11 @@ import androidx.paging.LoadType
|
|||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import app.pachli.BuildConfig
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import timber.log.Timber
|
||||
|
@ -36,16 +35,12 @@ import timber.log.Timber
|
|||
/** Remote mediator for accessing timelines that are not backed by the database. */
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class NetworkTimelineRemoteMediator(
|
||||
private val viewModelScope: CoroutineScope,
|
||||
private val api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
private val activeAccount: AccountEntity,
|
||||
private val factory: InvalidatingPagingSourceFactory<String, Status>,
|
||||
private val pageCache: PageCache,
|
||||
private val timeline: Timeline,
|
||||
) : RemoteMediator<String, Status>() {
|
||||
|
||||
private val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
override suspend fun load(loadType: LoadType, state: PagingState<String, Status>): MediatorResult {
|
||||
if (!activeAccount.isLoggedIn()) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
package app.pachli.components.timeline.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
|
@ -31,15 +30,14 @@ import app.pachli.appstore.PinEvent
|
|||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.components.timeline.NetworkTimelineRepository
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.FilterAction
|
||||
import app.pachli.core.network.model.Poll
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -54,21 +52,17 @@ import timber.log.Timber
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class NetworkTimelineViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: NetworkTimelineRepository,
|
||||
timelineCases: TimelineCases,
|
||||
eventHub: EventHub,
|
||||
contentFiltersRepository: ContentFiltersRepository,
|
||||
accountManager: AccountManager,
|
||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
) : TimelineViewModel(
|
||||
context,
|
||||
savedStateHandle,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
contentFiltersRepository,
|
||||
accountManager,
|
||||
statusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository,
|
||||
|
@ -78,18 +72,19 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
override var statuses: Flow<PagingData<StatusViewData>>
|
||||
|
||||
init {
|
||||
statuses = reload
|
||||
statuses = refreshFlow
|
||||
.flatMapLatest {
|
||||
getStatuses(initialKey = getInitialKey())
|
||||
getStatuses(it.second, initialKey = getInitialKey())
|
||||
}.cachedIn(viewModelScope)
|
||||
}
|
||||
|
||||
/** @return Flow of statuses that make up the timeline of [timeline] */
|
||||
/** @return Flow of statuses that make up the timeline of [timeline] for [account]. */
|
||||
private fun getStatuses(
|
||||
account: AccountEntity,
|
||||
initialKey: String? = null,
|
||||
): Flow<PagingData<StatusViewData>> {
|
||||
Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey)
|
||||
return repository.getStatusStream(viewModelScope, kind = timeline, initialKey = initialKey)
|
||||
return repository.getStatusStream(account, kind = timeline, initialKey = initialKey)
|
||||
.map { pagingData ->
|
||||
pagingData.map {
|
||||
modifiedViewData[it.id] ?: StatusViewData.from(
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
package app.pachli.components.timeline.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
|
@ -43,8 +42,9 @@ import app.pachli.appstore.StatusEditedEvent
|
|||
import app.pachli.appstore.UnfollowEvent
|
||||
import app.pachli.core.common.extensions.throttleFirst
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.Loadable
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.ContentFilterVersion
|
||||
import app.pachli.core.model.FilterAction
|
||||
import app.pachli.core.model.FilterContext
|
||||
|
@ -58,9 +58,6 @@ import app.pachli.network.ContentFilterModel
|
|||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
@ -68,7 +65,9 @@ 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.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
@ -270,13 +269,9 @@ sealed interface UiError {
|
|||
}
|
||||
|
||||
abstract class TimelineViewModel(
|
||||
// TODO: Context is required because handling filter errors needs to
|
||||
// format a resource string. As soon as that is removed this can be removed.
|
||||
@ApplicationContext private val context: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val eventHub: EventHub,
|
||||
private val contentFiltersRepository: ContentFiltersRepository,
|
||||
protected val accountManager: AccountManager,
|
||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
|
@ -323,7 +318,17 @@ abstract class TimelineViewModel(
|
|||
private var filterRemoveReblogs = false
|
||||
private var filterRemoveSelfReblogs = false
|
||||
|
||||
protected val activeAccount = accountManager.activeAccount!!
|
||||
protected val activeAccount: AccountEntity
|
||||
get() {
|
||||
return accountManager.activeAccount!!
|
||||
}
|
||||
|
||||
protected val refreshFlow = reload.combine(
|
||||
accountManager.activeAccountFlow
|
||||
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||
.filter { it.data != null }
|
||||
.distinctUntilChangedBy { it.data?.id!! },
|
||||
) { refresh, account -> Pair(refresh, account.data!!) }
|
||||
|
||||
/** The ID of the status to which the user's reading position should be restored */
|
||||
// Not part of the UiState as it's only used once in the lifespan of the fragment.
|
||||
|
@ -336,21 +341,18 @@ abstract class TimelineViewModel(
|
|||
init {
|
||||
viewModelScope.launch {
|
||||
FilterContext.from(timeline)?.let { filterContext ->
|
||||
contentFiltersRepository.contentFilters.fold(false) { reload, filters ->
|
||||
filters.onSuccess {
|
||||
contentFilterModel = when (it?.version) {
|
||||
accountManager.activePachliAccountFlow
|
||||
.distinctUntilChangedBy { it.contentFilters }
|
||||
.fold(false) { reload, account ->
|
||||
contentFilterModel = when (account.contentFilters.version) {
|
||||
ContentFilterVersion.V2 -> ContentFilterModel(filterContext)
|
||||
ContentFilterVersion.V1 -> ContentFilterModel(filterContext, it.contentFilters)
|
||||
else -> null
|
||||
ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters)
|
||||
}
|
||||
if (reload) {
|
||||
reloadKeepingReadingPosition(activeAccount.id)
|
||||
reloadKeepingReadingPosition(account.id)
|
||||
}
|
||||
}.onFailure {
|
||||
_uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context))))
|
||||
true
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -452,9 +454,8 @@ abstract class TimelineViewModel(
|
|||
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { action ->
|
||||
Timber.d("Saving Home timeline position at: %s", action.visibleId)
|
||||
activeAccount.lastVisibleHomeTimelineStatusId = action.visibleId
|
||||
accountManager.saveAccount(activeAccount)
|
||||
Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId)
|
||||
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, action.visibleId)
|
||||
readingPositionId = action.visibleId
|
||||
}
|
||||
}
|
||||
|
@ -466,8 +467,7 @@ abstract class TimelineViewModel(
|
|||
.filterIsInstance<InfallibleUiAction.LoadNewest>()
|
||||
.collectLatest {
|
||||
if (timeline == Timeline.Home) {
|
||||
activeAccount.lastVisibleHomeTimelineStatusId = null
|
||||
accountManager.saveAccount(activeAccount)
|
||||
accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, null)
|
||||
}
|
||||
Timber.d("Reload because InfallibleUiAction.LoadNewest")
|
||||
reloadFromNewest(activeAccount.id)
|
||||
|
|
|
@ -28,7 +28,6 @@ import androidx.lifecycle.lifecycleScope
|
|||
import app.pachli.R
|
||||
import app.pachli.TabViewData
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.MainTabsChangedEvent
|
||||
import app.pachli.core.activity.BottomSheetActivity
|
||||
import app.pachli.core.activity.ReselectableFragment
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
|
@ -136,9 +135,7 @@ class TrendingActivity : BottomSheetActivity(), AppBarLayoutHost, MenuProvider {
|
|||
val timeline = tabViewData.timeline
|
||||
accountManager.activeAccount?.let {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
it.tabPreferences += timeline
|
||||
accountManager.saveAccount(it)
|
||||
eventHub.dispatch(MainTabsChangedEvent(it.tabPreferences))
|
||||
accountManager.setTabPreferences(it.id, it.tabPreferences + timeline)
|
||||
}
|
||||
}
|
||||
Toast.makeText(this, getString(R.string.action_add_to_tab_success, tabViewData.title(this)), Toast.LENGTH_LONG).show()
|
||||
|
|
|
@ -18,7 +18,7 @@ package app.pachli.components.trending.viewmodel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.model.FilterContext
|
||||
import app.pachli.core.network.model.TrendingTag
|
||||
import app.pachli.core.network.model.end
|
||||
|
@ -26,19 +26,24 @@ import app.pachli.core.network.model.start
|
|||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.viewdata.TrendingViewData
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.get
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class TrendingTagsViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val contentFiltersRepository: ContentFiltersRepository,
|
||||
private val accountManager: AccountManager,
|
||||
) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL,
|
||||
|
@ -57,9 +62,18 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
val uiState: Flow<TrendingTagsUiState> get() = _uiState
|
||||
private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL))
|
||||
|
||||
private val contentFilters = flow {
|
||||
accountManager.activePachliAccountFlow.filterNotNull()
|
||||
.distinctUntilChangedBy { it.contentFilters }
|
||||
.map { it.contentFilters }
|
||||
.collect(::emit)
|
||||
}
|
||||
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
|
||||
|
||||
init {
|
||||
invalidate()
|
||||
viewModelScope.launch { contentFiltersRepository.contentFilters.collect { invalidate() } }
|
||||
viewModelScope.launch {
|
||||
contentFilters.collect { invalidate() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -74,21 +88,22 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING)
|
||||
}
|
||||
|
||||
val contentFilters = contentFilters.replayCache.last()
|
||||
|
||||
mastodonApi.trendingTags(limit = LIMIT_TRENDING_HASHTAGS).fold(
|
||||
{ tagResponse ->
|
||||
|
||||
val firstTag = tagResponse.firstOrNull()
|
||||
_uiState.value = if (firstTag == null) {
|
||||
TrendingTagsUiState(emptyList(), LoadingState.LOADED)
|
||||
} else {
|
||||
val homeFilters = contentFiltersRepository.contentFilters.value.get()?.contentFilters?.filter { filter ->
|
||||
val homeFilters = contentFilters.contentFilters.filter { filter ->
|
||||
filter.contexts.contains(FilterContext.HOME)
|
||||
}
|
||||
val tags = tagResponse
|
||||
.filter { tag ->
|
||||
homeFilters?.none { filter ->
|
||||
homeFilters.none { filter ->
|
||||
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
|
||||
} ?: false
|
||||
}
|
||||
}
|
||||
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
||||
.toTrendingViewDataTag()
|
||||
|
|
|
@ -279,8 +279,8 @@ class ViewThreadFragment :
|
|||
viewModel.refresh(thisThreadsStatusId)
|
||||
}
|
||||
|
||||
override fun onReply(viewData: StatusViewData) {
|
||||
super.reply(viewData.actionable)
|
||||
override fun onReply(pachliAccountId: Long, viewData: StatusViewData) {
|
||||
super.reply(pachliAccountId, viewData.actionable)
|
||||
}
|
||||
|
||||
override fun onReblog(viewData: StatusViewData, reblog: Boolean) {
|
||||
|
|
|
@ -30,7 +30,7 @@ import app.pachli.appstore.StatusEditedEvent
|
|||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.components.timeline.util.ifExpected
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.Loadable
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.dao.TimelineDao
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
|
@ -48,8 +48,6 @@ import app.pachli.viewdata.StatusViewData
|
|||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
@ -59,6 +57,10 @@ import kotlinx.coroutines.channels.BufferOverflow
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
@ -69,14 +71,12 @@ class ViewThreadViewModel @Inject constructor(
|
|||
private val api: MastodonApi,
|
||||
private val timelineCases: TimelineCases,
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager,
|
||||
private val accountManager: AccountManager,
|
||||
private val timelineDao: TimelineDao,
|
||||
private val moshi: Moshi,
|
||||
private val repository: CachedTimelineRepository,
|
||||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||
private val contentFiltersRepository: ContentFiltersRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
|
||||
val uiState: Flow<ThreadUiState>
|
||||
get() = _uiState
|
||||
|
@ -89,9 +89,10 @@ class ViewThreadViewModel @Inject constructor(
|
|||
|
||||
val statusDisplayOptions = statusDisplayOptionsRepository.flow
|
||||
|
||||
val activeAccount: AccountEntity = accountManager.activeAccount!!
|
||||
private val alwaysShowSensitiveMedia: Boolean = activeAccount.alwaysShowSensitiveMedia
|
||||
private val alwaysOpenSpoiler: Boolean = activeAccount.alwaysOpenSpoiler
|
||||
val activeAccount: AccountEntity
|
||||
get() {
|
||||
return accountManager.activeAccount!!
|
||||
}
|
||||
|
||||
private var contentFilterModel: ContentFilterModel? = null
|
||||
|
||||
|
@ -113,28 +114,27 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
contentFiltersRepository.contentFilters.collect { filters ->
|
||||
filters.onSuccess {
|
||||
contentFilterModel = when (it?.version) {
|
||||
ContentFilterVersion.V2 -> ContentFilterModel(FilterContext.THREAD)
|
||||
ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.THREAD, it.contentFilters)
|
||||
else -> null
|
||||
accountManager.activePachliAccountFlow
|
||||
.distinctUntilChangedBy { it.contentFilters }
|
||||
.collect { account ->
|
||||
contentFilterModel = when (account.contentFilters.version) {
|
||||
ContentFilterVersion.V2 -> ContentFilterModel(FilterContext.NOTIFICATIONS)
|
||||
ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.NOTIFICATIONS, account.contentFilters.contentFilters)
|
||||
}
|
||||
updateStatuses()
|
||||
}
|
||||
.onFailure {
|
||||
// TODO: Deliberately don't emit to _errors here -- at the moment
|
||||
// ViewThreadFragment shows a generic error to the user, and that
|
||||
// would confuse them when the rest of the thread is loading OK.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadThread(id: String) {
|
||||
_uiState.value = ThreadUiState.Loading
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = ThreadUiState.Loading
|
||||
|
||||
val account = accountManager.activeAccountFlow
|
||||
.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||
.filter { it.data != null }
|
||||
.first().data!!
|
||||
|
||||
Timber.d("Finding status with: %s", id)
|
||||
val contextCall = async { api.statusContext(id) }
|
||||
val timelineStatusWithAccount = timelineDao.getStatus(id)
|
||||
|
@ -150,8 +150,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
if (status.actionableId == id) {
|
||||
StatusViewData.from(
|
||||
status = status.actionableStatus,
|
||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: alwaysOpenSpoiler,
|
||||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler,
|
||||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
|
||||
isDetailed = true,
|
||||
translationState = timelineStatusWithAccount.viewData?.translationState ?: TranslationState.SHOW_ORIGINAL,
|
||||
|
@ -161,8 +161,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
StatusViewData.from(
|
||||
timelineStatusWithAccount,
|
||||
moshi,
|
||||
isExpanded = alwaysOpenSpoiler,
|
||||
isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = account.alwaysOpenSpoiler,
|
||||
isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isDetailed = true,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
|
@ -173,7 +173,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
_uiState.value = ThreadUiState.Error(exception)
|
||||
return@launch
|
||||
}
|
||||
StatusViewData.fromStatusAndUiState(result, isDetailed = true)
|
||||
StatusViewData.fromStatusAndUiState(account, result, isDetailed = true)
|
||||
}
|
||||
|
||||
_uiState.value = ThreadUiState.LoadingThread(
|
||||
|
@ -210,8 +210,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
val svd = cachedViewData[status.id]
|
||||
StatusViewData.from(
|
||||
status,
|
||||
isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
|
||||
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
|
||||
isCollapsed = svd?.contentCollapsed ?: true,
|
||||
isDetailed = false,
|
||||
translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL,
|
||||
|
@ -222,8 +222,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
val svd = cachedViewData[status.id]
|
||||
StatusViewData.from(
|
||||
status,
|
||||
isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
|
||||
isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler,
|
||||
isCollapsed = svd?.contentCollapsed ?: true,
|
||||
isDetailed = false,
|
||||
translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL,
|
||||
|
@ -403,7 +403,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
||||
// there is a new reply to the detailed status or below -> display it
|
||||
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
||||
StatusViewData.fromStatusAndUiState(eventStatus) +
|
||||
StatusViewData.fromStatusAndUiState(activeAccount, eventStatus) +
|
||||
statuses.subList(repliedIndex + 1, statuses.size)
|
||||
uiState.copy(statusViewData = newStatuses)
|
||||
} else {
|
||||
|
@ -417,7 +417,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
uiState.copy(
|
||||
statusViewData = uiState.statusViewData.map { status ->
|
||||
if (status.actionableId == event.originalId) {
|
||||
StatusViewData.fromStatusAndUiState(event.status)
|
||||
StatusViewData.fromStatusAndUiState(activeAccount, event.status)
|
||||
} else {
|
||||
status
|
||||
}
|
||||
|
@ -562,12 +562,12 @@ class ViewThreadViewModel @Inject constructor(
|
|||
* Creates a [StatusViewData] from `status`, copying over the viewdata state from the same
|
||||
* status in _uiState (if that status exists).
|
||||
*/
|
||||
private fun StatusViewData.Companion.fromStatusAndUiState(status: Status, isDetailed: Boolean = false): StatusViewData {
|
||||
private fun StatusViewData.Companion.fromStatusAndUiState(account: AccountEntity, status: Status, isDetailed: Boolean = false): StatusViewData {
|
||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id }
|
||||
return from(
|
||||
status,
|
||||
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
|
||||
isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler,
|
||||
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
|
||||
isDetailed = oldStatus?.isDetailed ?: isDetailed,
|
||||
)
|
||||
|
|
|
@ -170,7 +170,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
|
|||
bottomSheetActivity.viewUrl(pachliAccountId, url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
|
||||
}
|
||||
|
||||
protected fun reply(status: Status) {
|
||||
protected fun reply(pachliAccountId: Long, status: Status) {
|
||||
val actionableStatus = status.actionableStatus
|
||||
val account = actionableStatus.account
|
||||
var loggedInUsername: String? = null
|
||||
|
@ -193,7 +193,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
|
|||
kind = ComposeOptions.ComposeKind.NEW,
|
||||
)
|
||||
|
||||
val intent = ComposeActivityIntent(requireContext(), composeOptions)
|
||||
val intent = ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions)
|
||||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
|
@ -489,7 +489,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
|
|||
poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt),
|
||||
kind = ComposeOptions.ComposeKind.NEW,
|
||||
)
|
||||
startActivity(ComposeActivityIntent(requireContext(), composeOptions))
|
||||
startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions))
|
||||
},
|
||||
{ error: Throwable? ->
|
||||
Timber.w(error, "error deleting status")
|
||||
|
@ -519,7 +519,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
|
|||
poll = status.poll?.toNewPoll(status.createdAt),
|
||||
kind = ComposeOptions.ComposeKind.EDIT_POSTED,
|
||||
)
|
||||
startActivity(ComposeActivityIntent(requireContext(), composeOptions))
|
||||
startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions))
|
||||
},
|
||||
{
|
||||
Snackbar.make(
|
||||
|
|
|
@ -24,7 +24,7 @@ import app.pachli.core.ui.LinkListener
|
|||
import app.pachli.viewdata.IStatusViewData
|
||||
|
||||
interface StatusActionListener<T : IStatusViewData> : LinkListener {
|
||||
fun onReply(viewData: T)
|
||||
fun onReply(pachliAccountId: Long, viewData: T)
|
||||
fun onReblog(viewData: T, reblog: Boolean)
|
||||
fun onFavourite(viewData: T, favourite: Boolean)
|
||||
fun onBookmark(viewData: T, bookmark: Boolean)
|
||||
|
|
|
@ -54,6 +54,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
lateinit var accountManager: AccountManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// The user has used the "quick reply" feature on a notification.
|
||||
if (intent.action == REPLY_ACTION) {
|
||||
val notificationId = intent.getIntExtra(KEY_NOTIFICATION_ID, -1)
|
||||
val senderId = intent.getLongExtra(KEY_SENDER_ACCOUNT_ID, -1)
|
||||
|
|
|
@ -20,7 +20,6 @@ import android.annotation.TargetApi
|
|||
import android.app.PendingIntent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.TileService
|
||||
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
|
||||
import app.pachli.core.navigation.MainActivityIntent
|
||||
|
||||
/**
|
||||
|
@ -30,8 +29,7 @@ import app.pachli.core.navigation.MainActivityIntent
|
|||
@TargetApi(24)
|
||||
class PachliTileService : TileService() {
|
||||
override fun onClick() {
|
||||
// XXX: -1L here needs handling properly.
|
||||
val intent = MainActivityIntent.openCompose(this, ComposeOptions(), -1L)
|
||||
val intent = MainActivityIntent.fromQuickTile(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
|
|
|
@ -223,14 +223,14 @@ class SendStatusService : Service() {
|
|||
val sendResult = if (isNew) {
|
||||
if (newStatus.scheduledAt == null) {
|
||||
mastodonApi.createStatus(
|
||||
"Bearer " + account.accessToken,
|
||||
account.authHeader,
|
||||
account.domain,
|
||||
statusToSend.idempotencyKey,
|
||||
newStatus,
|
||||
)
|
||||
} else {
|
||||
mastodonApi.createScheduledStatus(
|
||||
"Bearer " + account.accessToken,
|
||||
account.authHeader,
|
||||
account.domain,
|
||||
statusToSend.idempotencyKey,
|
||||
newStatus,
|
||||
|
@ -239,7 +239,7 @@ class SendStatusService : Service() {
|
|||
} else {
|
||||
mastodonApi.editStatus(
|
||||
statusToSend.statusId!!,
|
||||
"Bearer " + account.accessToken,
|
||||
account.authHeader,
|
||||
account.domain,
|
||||
statusToSend.idempotencyKey,
|
||||
newStatus,
|
||||
|
@ -402,10 +402,10 @@ class SendStatusService : Service() {
|
|||
private fun buildDraftNotification(
|
||||
@StringRes title: Int,
|
||||
@StringRes content: Int,
|
||||
accountId: Long,
|
||||
pachliAccountId: Long,
|
||||
statusId: Int,
|
||||
): Notification {
|
||||
val intent = MainActivityIntent.openDrafts(this, accountId)
|
||||
val intent = MainActivityIntent.fromDraftsNotification(this, pachliAccountId)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
package app.pachli.usecase
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import app.pachli.components.drafts.DraftHelper
|
||||
import app.pachli.components.notifications.deleteNotificationChannelsForAccount
|
||||
import app.pachli.components.notifications.disablePushNotificationsForAccount
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.LogoutError
|
||||
import app.pachli.core.database.dao.ConversationsDao
|
||||
import app.pachli.core.database.dao.RemoteKeyDao
|
||||
import app.pachli.core.database.dao.TimelineDao
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.util.removeShortcut
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import timber.log.Timber
|
||||
|
||||
class LogoutUseCase @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
|
@ -25,51 +28,39 @@ class LogoutUseCase @Inject constructor(
|
|||
private val draftHelper: DraftHelper,
|
||||
) {
|
||||
/**
|
||||
* Logs the current account out and clears all caches associated with it
|
||||
* Logs the current account out and clears all caches associated with it. The next
|
||||
* account is automatically made active.
|
||||
*
|
||||
* @return The [AccountEntity] that should be logged in next, null if there are no
|
||||
* other accounts to log in to.
|
||||
* @return [Result] of the [AccountEntity] that is now active, null if there are no
|
||||
* other accounts to log in to. Or the error that occurred during logout.
|
||||
*/
|
||||
suspend operator fun invoke(): AccountEntity? {
|
||||
accountManager.activeAccount?.let { activeAccount ->
|
||||
suspend operator fun invoke(account: AccountEntity): Result<AccountEntity?, LogoutError> {
|
||||
disablePushNotificationsForAccount(context, api, accountManager, account)
|
||||
|
||||
// invalidate the oauth token, if we have the client id & secret
|
||||
// (could be missing if user logged in with a previous version of the app)
|
||||
val clientId = activeAccount.clientId
|
||||
val clientSecret = activeAccount.clientSecret
|
||||
if (clientId != null && clientSecret != null) {
|
||||
try {
|
||||
api.revokeOAuthToken(
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
token = activeAccount.accessToken,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Could not revoke OAuth token, continuing")
|
||||
}
|
||||
}
|
||||
api.revokeOAuthToken(
|
||||
clientId = account.clientId,
|
||||
clientSecret = account.clientSecret,
|
||||
token = account.accessToken,
|
||||
)
|
||||
.onFailure { return Err(LogoutError.Api(it)) }
|
||||
|
||||
// disable push notifications
|
||||
disablePushNotificationsForAccount(context, api, accountManager, activeAccount)
|
||||
// clear notification channels
|
||||
deleteNotificationChannelsForAccount(account, context)
|
||||
|
||||
// clear notification channels
|
||||
deleteNotificationChannelsForAccount(activeAccount, context)
|
||||
val nextAccount = accountManager.logActiveAccountOut()
|
||||
.onFailure { return Err(it) }
|
||||
|
||||
// remove account from local AccountManager
|
||||
val nextAccount = accountManager.logActiveAccountOut()
|
||||
// Clear the database.
|
||||
// TODO: This should be handled with foreign key constraints.
|
||||
timelineDao.removeAll(account.id)
|
||||
timelineDao.removeAllStatusViewData(account.id)
|
||||
remoteKeyDao.delete(account.id)
|
||||
conversationsDao.deleteForAccount(account.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(account.id)
|
||||
|
||||
// clear the database - this could trigger network calls so do it last when all tokens are gone
|
||||
timelineDao.removeAll(activeAccount.id)
|
||||
timelineDao.removeAllStatusViewData(activeAccount.id)
|
||||
remoteKeyDao.delete(activeAccount.id)
|
||||
conversationsDao.deleteForAccount(activeAccount.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||
// remove shortcut associated with the account
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString()))
|
||||
|
||||
// remove shortcut associated with the account
|
||||
removeShortcut(context, activeAccount)
|
||||
|
||||
return nextAccount
|
||||
}
|
||||
return null
|
||||
return nextAccount
|
||||
}
|
||||
}
|
||||
|
|
|
@ -133,7 +133,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
|
|||
when (action) {
|
||||
app.pachli.core.ui.R.id.action_reply -> {
|
||||
interrupt()
|
||||
statusActionListener.onReply(status)
|
||||
statusActionListener.onReply(pachliAccountId, status)
|
||||
}
|
||||
app.pachli.core.ui.R.id.action_favourite -> statusActionListener.onFavourite(status, true)
|
||||
app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false)
|
||||
|
|
|
@ -1,101 +0,0 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.text.TextUtils
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import app.pachli.core.navigation.MainActivityIntent
|
||||
import com.bumptech.glide.Glide
|
||||
import java.util.concurrent.ExecutionException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
suspend fun updateShortcuts(context: Context, accountManager: AccountManager) = withContext(Dispatchers.IO) {
|
||||
val innerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_inner_size)
|
||||
val outerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_outer_size)
|
||||
|
||||
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
|
||||
|
||||
val shortcuts = accountManager.getAllAccountsOrderedByActive().take(maxShortcuts).mapNotNull { account ->
|
||||
val drawable = try {
|
||||
if (TextUtils.isEmpty(account.profilePictureUrl)) {
|
||||
AppCompatResources.getDrawable(context, DR.drawable.avatar_default)
|
||||
} else {
|
||||
Glide.with(context)
|
||||
.asDrawable()
|
||||
.load(account.profilePictureUrl)
|
||||
.error(DR.drawable.avatar_default)
|
||||
.submit(innerSize, innerSize)
|
||||
.get()
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
// The `.error` handler isn't always used. For example, Glide throws
|
||||
// ExecutionException if the URL does not point at an image. Fallback to
|
||||
// the default avatar (https://github.com/bumptech/glide/issues/4672).
|
||||
AppCompatResources.getDrawable(context, DR.drawable.avatar_default)
|
||||
} ?: return@mapNotNull null
|
||||
|
||||
// inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon
|
||||
val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888)
|
||||
|
||||
val canvas = Canvas(outBmp)
|
||||
val border = (outerSize - innerSize) / 2
|
||||
drawable.setBounds(border, border, border + innerSize, border + innerSize)
|
||||
drawable.draw(canvas)
|
||||
|
||||
val icon = IconCompat.createWithAdaptiveBitmap(outBmp)
|
||||
|
||||
val person = Person.Builder()
|
||||
.setIcon(icon)
|
||||
.setName(account.displayName)
|
||||
.setKey(account.identifier)
|
||||
.build()
|
||||
|
||||
// This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different
|
||||
val intent = MainActivityIntent(context, account.id).apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString())
|
||||
}
|
||||
|
||||
ShortcutInfoCompat.Builder(context, account.id.toString())
|
||||
.setIntent(intent)
|
||||
.setCategories(setOf("app.pachli.Share"))
|
||||
.setShortLabel(account.displayName)
|
||||
.setPerson(person)
|
||||
.setLongLived(true)
|
||||
.setIcon(icon)
|
||||
.build()
|
||||
}
|
||||
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
|
||||
}
|
||||
|
||||
fun removeShortcut(context: Context, account: AccountEntity) {
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString()))
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.text.TextUtils
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import app.pachli.core.navigation.MainActivityIntent
|
||||
import com.bumptech.glide.Glide
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.ExecutionException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class UpdateShortCutsUseCase @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
) {
|
||||
/**
|
||||
* Updates shortcuts to reflect [accounts].
|
||||
*
|
||||
* The first [N][ShortcutManagerCompat.getMaxShortcutCountPerActivity] accounts
|
||||
* are converted to shortcuts which launch [app.pachli.MainActivity]. The
|
||||
* active account is always included.
|
||||
*/
|
||||
suspend operator fun invoke(accounts: List<AccountEntity>) = withContext(Dispatchers.IO) {
|
||||
val innerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_inner_size)
|
||||
val outerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_outer_size)
|
||||
|
||||
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
|
||||
|
||||
val shortcuts = accounts
|
||||
.sortedBy { it.isActive }
|
||||
.take(maxShortcuts)
|
||||
.mapNotNull { account ->
|
||||
val drawable = try {
|
||||
if (TextUtils.isEmpty(account.profilePictureUrl)) {
|
||||
AppCompatResources.getDrawable(context, DR.drawable.avatar_default)
|
||||
} else {
|
||||
Glide.with(context)
|
||||
.asDrawable()
|
||||
.load(account.profilePictureUrl)
|
||||
.error(DR.drawable.avatar_default)
|
||||
.submit(innerSize, innerSize)
|
||||
.get()
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
// The `.error` handler isn't always used. For example, Glide throws
|
||||
// ExecutionException if the URL does not point at an image. Fallback to
|
||||
// the default avatar (https://github.com/bumptech/glide/issues/4672).
|
||||
AppCompatResources.getDrawable(context, DR.drawable.avatar_default)
|
||||
} ?: return@mapNotNull null
|
||||
|
||||
// inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon
|
||||
val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888)
|
||||
|
||||
val canvas = Canvas(outBmp)
|
||||
val border = (outerSize - innerSize) / 2
|
||||
drawable.setBounds(border, border, border + innerSize, border + innerSize)
|
||||
drawable.draw(canvas)
|
||||
|
||||
val icon = IconCompat.createWithAdaptiveBitmap(outBmp)
|
||||
|
||||
val person = Person.Builder()
|
||||
.setIcon(icon)
|
||||
.setName(account.displayName)
|
||||
.setKey(account.identifier)
|
||||
.build()
|
||||
|
||||
// This intent will be sent when the user clicks on one of the launcher shortcuts.
|
||||
// Intent from share sheet will be different
|
||||
val intent = MainActivityIntent.fromShortcut(context, account.id)
|
||||
|
||||
ShortcutInfoCompat.Builder(context, account.id.toString())
|
||||
.setIntent(intent)
|
||||
.setCategories(setOf("app.pachli.Share"))
|
||||
.setShortLabel(account.displayName.ifBlank { account.fullName })
|
||||
.setPerson(person)
|
||||
.setLongLived(true)
|
||||
.setIcon(icon)
|
||||
.build()
|
||||
}
|
||||
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
|
||||
}
|
||||
}
|
|
@ -34,6 +34,16 @@ import app.pachli.core.network.model.TimelineAccount
|
|||
* about boosting a status, the boosted status is also shown). However, not all
|
||||
* notifications are related to statuses (e.g., a "Someone has followed you"
|
||||
* notification) so `statusViewData` is nullable.
|
||||
*
|
||||
* @param type
|
||||
* @param id
|
||||
* @param account
|
||||
* @param statusViewData
|
||||
* @param report
|
||||
* @param relationshipSeveranceEvent
|
||||
* @param isAboutSelf True if this notification relates to something the user
|
||||
* posted (e.g., it's a boost, favourite, or poll ending), false otherwise
|
||||
* (e.g., it's a mention).
|
||||
*/
|
||||
data class NotificationViewData(
|
||||
val type: Notification.Type,
|
||||
|
@ -42,6 +52,7 @@ data class NotificationViewData(
|
|||
var statusViewData: StatusViewData?,
|
||||
val report: Report?,
|
||||
val relationshipSeveranceEvent: RelationshipSeveranceEvent?,
|
||||
val isAboutSelf: Boolean,
|
||||
) : IStatusViewData {
|
||||
companion object {
|
||||
fun from(
|
||||
|
@ -50,6 +61,7 @@ data class NotificationViewData(
|
|||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
filterAction: FilterAction,
|
||||
isAboutSelf: Boolean,
|
||||
) = NotificationViewData(
|
||||
notification.type,
|
||||
notification.id,
|
||||
|
@ -65,6 +77,7 @@ data class NotificationViewData(
|
|||
},
|
||||
notification.report,
|
||||
notification.relationshipSeveranceEvent,
|
||||
isAboutSelf,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.ProfileEditedEvent
|
||||
import app.pachli.core.common.string.randomAlphanumericString
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.InstanceInfoRepository
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.StringField
|
||||
|
@ -34,6 +35,8 @@ import app.pachli.util.Loading
|
|||
import app.pachli.util.Resource
|
||||
import app.pachli.util.Success
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
@ -61,6 +64,7 @@ class EditProfileViewModel @Inject constructor(
|
|||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val application: Application,
|
||||
private val accountManager: AccountManager,
|
||||
instanceInfoRepo: InstanceInfoRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
|
@ -86,13 +90,12 @@ class EditProfileViewModel @Inject constructor(
|
|||
if (profileData.value == null || profileData.value is Error) {
|
||||
profileData.postValue(Loading())
|
||||
|
||||
mastodonApi.accountVerifyCredentials().fold(
|
||||
{ profile ->
|
||||
apiProfileAccount = profile
|
||||
profileData.postValue(Success(profile))
|
||||
},
|
||||
{ profileData.postValue(Error()) },
|
||||
)
|
||||
mastodonApi.accountVerifyCredentials()
|
||||
.onSuccess { profile ->
|
||||
apiProfileAccount = profile.body
|
||||
profileData.postValue(Success(profile.body))
|
||||
}
|
||||
.onFailure { profileData.postValue(Error()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,6 +152,7 @@ class EditProfileViewModel @Inject constructor(
|
|||
diff.field4?.second?.toRequestBody(MultipartBody.FORM),
|
||||
).fold(
|
||||
{ newAccountData ->
|
||||
accountManager.updateAccount(pachliAccountId, newAccountData)
|
||||
saveData.postValue(Success())
|
||||
eventHub.dispatch(ProfileEditedEvent(newAccountData))
|
||||
},
|
||||
|
|
|
@ -53,6 +53,8 @@ class NotificationWorker @AssistedInject constructor(
|
|||
|
||||
companion object {
|
||||
private const val ACCOUNT_ID = "accountId"
|
||||
|
||||
/** Notifications for all accounts should be fetched. */
|
||||
const val ALL_ACCOUNTS = -1L
|
||||
|
||||
fun data(accountId: Long) = Data.Builder().putLong(ACCOUNT_ID, accountId).build()
|
||||
|
|
|
@ -131,7 +131,8 @@
|
|||
android:id="@+id/composeContentWarningBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<androidx.emoji2.widget.EmojiEditText
|
||||
android:id="@+id/composeContentWarningField"
|
||||
|
|
|
@ -10,6 +10,14 @@
|
|||
android:id="@+id/includedToolbar"
|
||||
layout="@layout/toolbar_basic" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressIndicator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:indeterminate="true"
|
||||
android:contentDescription="" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_marginTop="?attr/actionBarSize"
|
||||
|
@ -35,12 +43,6 @@
|
|||
</FrameLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/addFilterButton"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
@ -605,7 +605,6 @@
|
|||
<string name="pref_title_font_family">عائلة الخطوط</string>
|
||||
<string name="notification_listenable_worker_description">إشعارات عندما يعمل Pachli (باكلي) في الخلفية</string>
|
||||
<string name="status_filtered_show_anyway">إظهار على أي حال</string>
|
||||
<string name="error_list_load">خطأ في تحميل القوائم</string>
|
||||
<string name="action_translate">ترجمة</string>
|
||||
<string name="select_list_empty">ليس لديك قوائم بعد</string>
|
||||
<string name="pref_title_account_filter_keywords">الملفات التعريفية</string>
|
||||
|
@ -625,4 +624,4 @@
|
|||
<string name="pref_summary_content_filters">خادمك الخاص لا يدعم عوامل التصفية</string>
|
||||
<string name="load_newest_statuses">تحميل أحدث المنشورات</string>
|
||||
<string name="announcement_date_updated">(%1$s :تم تحديثه)</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -543,7 +543,6 @@
|
|||
<string name="post_media_image">Відарыс</string>
|
||||
<string name="select_list_empty">Яшчэ няма спісаў</string>
|
||||
<string name="select_list_manage">Кіраванне спісамі</string>
|
||||
<string name="error_list_load">Памылка загрузкі спісаў</string>
|
||||
<string name="total_usage">Усяго выкарыстана</string>
|
||||
<string name="total_accounts">Усяго ўліковых запісаў</string>
|
||||
<string name="accessibility_talking_about_tag">%1$d людзей кажуць пра хэштэг %2$s</string>
|
||||
|
@ -565,4 +564,4 @@
|
|||
<string name="filter_keyword_display_format">%s (цэлае слова)</string>
|
||||
<string name="filter_keyword_addition_title">Дадаць ключавое слова</string>
|
||||
<string name="filter_edit_keyword_title">Змяніць ключавое слова</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -599,7 +599,6 @@
|
|||
<string name="post_media_image">Delwedd</string>
|
||||
<string name="select_list_empty">Nid oes gennych restrau, eto</string>
|
||||
<string name="select_list_manage">Rheoli rhestrau</string>
|
||||
<string name="error_list_load">Gwall wrth lwytho rhestrau</string>
|
||||
<string name="help_empty_home">Hwn yw\'ch <b>ffrwd cartref</b>. Mae\'n dangos negeseuon diweddar y cyfrifon rydych yn eu dilyn. \n \nI archwilio cyfrifon gallwch un ai eu darganfod o fewn un o\'r llinellau amser eraill. Er enghraifft, mae llinell amser eich enghraifft chi [iconics gmd_group]. Neu gallwch eu chwilio yn ôl eu henw [iconics gmd_search]; er enghraifft, chwilio am Pachli i ganfod ein cyfrif Mastodon.</string>
|
||||
<string name="pref_title_show_stat_inline">Dangos ystadegau negeseuon mewn llinell amser</string>
|
||||
<string name="pref_ui_text_size">Maint testun rhyngwyneb</string>
|
||||
|
@ -615,4 +614,4 @@
|
|||
<string name="error_media_playback">Methodd chwarae: %s</string>
|
||||
<string name="dialog_delete_filter_positive_action">Dileu</string>
|
||||
<string name="dialog_delete_filter_text">Dileu\'r hidlydd \'%1$s\'\?</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -357,7 +357,6 @@
|
|||
<string name="select_list_title">Liste auswählen</string>
|
||||
<string name="select_list_empty">Du hast noch keine Listen</string>
|
||||
<string name="select_list_manage">Listen verwalten</string>
|
||||
<string name="error_list_load">Fehler beim Laden der Listen</string>
|
||||
<string name="list">Liste</string>
|
||||
<string name="no_drafts">Du hast keine Entwürfe.</string>
|
||||
<string name="no_scheduled_posts">Du hast keine geplanten Beiträge.</string>
|
||||
|
@ -602,4 +601,4 @@
|
|||
<string name="reaction_name_and_count">%1$s %2$d</string>
|
||||
<string name="announcement_date">%1$s %2$s</string>
|
||||
<string name="announcement_date_updated">(Aktualisiert: %1$s)</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -545,7 +545,6 @@
|
|||
<string name="help_empty_home">Esta es tu <b> cronología de inicio</b>. Muestra las publicaciones recientes de las cuentas que sigues. \n \nPara encontrar cuentas, puedes mirar en alguna de las otras cronologías; por ejemplo, la cronología local de tu instancia [iconics gmd_group]. O puedes buscarlas por nombre [iconics gmd_search]; por ejemplo, busca Pachli para encontrar nuestra cuenta de Mastodon.</string>
|
||||
<string name="ui_error_bookmark_fmt">Fallo al añadir a marcadores: %1$s</string>
|
||||
<string name="select_list_manage">Gestionar listas</string>
|
||||
<string name="error_list_load">Error al cargar listas</string>
|
||||
<string name="ui_error_favourite_fmt">Fallo al favoritear publicación: %1$s</string>
|
||||
<string name="ui_error_clear_notifications">Fallo al limpiar notificaciones: %s</string>
|
||||
<string name="ui_error_accept_follow_request">Fallo al aceptar solicitud de seguimiento: %s</string>
|
||||
|
@ -635,8 +634,6 @@
|
|||
<string name="notification_severed_relationships_description">Notificaciones de relaciones rotas</string>
|
||||
<string name="poll_show_votes">Mostrar votos</string>
|
||||
<string name="manage_lists">Gestionar listas</string>
|
||||
<string name="title_lists_loading">Listas - cargando…</string>
|
||||
<string name="title_lists_failed">Listas - falló en cargar</string>
|
||||
<string name="error_filter_missing_keyword">Por lo menos se necesita una palabra clave o frase</string>
|
||||
<string name="error_filter_missing_context">Por lo menos se necesita un contexto para el filtro</string>
|
||||
<string name="error_filter_missing_title">Se necesita el título</string>
|
||||
|
@ -789,4 +786,4 @@
|
|||
<string name="conversation_0_recipients">Ningún participante más</string>
|
||||
<string name="pref_title_confirm_status_language">Revisar idioma de la publicación antes de publicar</string>
|
||||
<string name="compose_warn_language_dialog_accept_and_dont_ask_fmt">Publicar como está (%1$s) y no volver a preguntar</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -557,7 +557,6 @@
|
|||
<string name="post_media_image">تصویر</string>
|
||||
<string name="select_list_empty">هنوز هیچ سیاههای ندارید</string>
|
||||
<string name="select_list_manage">مدیریت سیاههها</string>
|
||||
<string name="error_list_load">خطا در بار کردن سیاههها</string>
|
||||
<string name="load_newest_notifications">بار کردن جدیدترین آگاهیها</string>
|
||||
<string name="compose_delete_draft">حذف پیشنویس؟</string>
|
||||
<string name="error_missing_edits">کارسازتان میداند که این فرسته ویرایش شده؛ ولی رونوشتی از ویرایشها ندارد. پس نمیتوانند نشانتان داده شوند.
|
||||
|
@ -571,4 +570,4 @@
|
|||
<string name="error_media_playback">پخش شکست خورد: %s</string>
|
||||
<string name="dialog_delete_filter_positive_action">حذف</string>
|
||||
<string name="dialog_delete_filter_text">«%1$s» حذف پالایهٔ ؟</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -568,7 +568,6 @@
|
|||
<string name="select_list_title">Valitse lista</string>
|
||||
<string name="duration_indefinite">Loputon</string>
|
||||
<string name="failed_search">Haku epäonnistui</string>
|
||||
<string name="error_list_load">Listojen lataaminen epäonnistui</string>
|
||||
<string name="report_sent_success">Raportti käyttäjästä @%s lähetetty</string>
|
||||
<string name="compose_shortcut_long_label">Kirjoita julkaisu</string>
|
||||
<plurals name="poll_info_people">
|
||||
|
@ -601,8 +600,6 @@
|
|||
<string name="pref_update_next_scheduled_check">Seuraava ajoitettu tarkistus: %1$s</string>
|
||||
<string name="pref_update_check_no_updates">Ei päivityksiä tarjolla</string>
|
||||
<string name="error_media_download">Lataaminen epäonnistui %1$s: %2$d%3$s</string>
|
||||
<string name="title_lists_loading">Listat - ladataan…</string>
|
||||
<string name="title_lists_failed">Listat - lataaminen epäonnistui</string>
|
||||
<string name="manage_lists">Hallinnoi listoja</string>
|
||||
<string name="poll_show_votes">Näytä äänet</string>
|
||||
<string name="notification_severed_relationships_name">Katkaistut yhteydet</string>
|
||||
|
@ -774,4 +771,4 @@
|
|||
<string name="pref_title_confirm_status_language">Tarkasta julkaisun kieli ennen julkaisemista</string>
|
||||
<string name="action_copy_item">Kopioi kohde</string>
|
||||
<string name="item_copied">Teksti kopioitu</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -548,7 +548,6 @@
|
|||
<string name="description_post_edited">Modifié</string>
|
||||
<string name="select_list_empty">Vous n\'avez pas encore de liste</string>
|
||||
<string name="select_list_manage">Gérer les listes</string>
|
||||
<string name="error_list_load">Erreur de chargement des listes</string>
|
||||
<string name="report_category_violation">Règle enfreinte</string>
|
||||
<string name="error_status_source_load">Le texte d\'origine du statut n\'a pas pu être chargé.</string>
|
||||
<string name="ui_error_clear_notifications">Échec du nettoyage des notifications : %s</string>
|
||||
|
@ -606,4 +605,4 @@
|
|||
<string name="notification_notification_worker">Récupération des notifications …</string>
|
||||
<string name="notification_prune_cache">Maintenance du cache …</string>
|
||||
<string name="announcement_date">%1$s %2$s</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -551,7 +551,6 @@
|
|||
<string name="ui_success_rejected_follow_request">Chaidh iarrtas leantainn a bhacadh</string>
|
||||
<string name="select_list_manage">Stiùirich na liostaichean</string>
|
||||
<string name="select_list_empty">Chan eil liosta agad fhathast</string>
|
||||
<string name="error_list_load">Mearachd a’ luchdadh nan liostaichean</string>
|
||||
<string name="status_filtered_show_anyway">Seall e co-dhiù</string>
|
||||
<string name="status_filter_placeholder_label_format">Criathraichte: <b>%1$s</b></string>
|
||||
<string name="pref_title_account_filter_keywords">Pròifilean</string>
|
||||
|
@ -583,4 +582,4 @@
|
|||
<string name="notification_listenable_worker_description">Brathan nuair a bhios Pachli ag obair sa chùlaibh</string>
|
||||
<string name="notification_notification_worker">A’ faighinn nam brathan…</string>
|
||||
<string name="notification_prune_cache">Obair-ghlèidhidh air an tasgadan…</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -550,7 +550,6 @@
|
|||
<string name="filter_edit_keyword_title">Editar palabra</string>
|
||||
<string name="select_list_empty">Aínda non tes listas</string>
|
||||
<string name="select_list_manage">Xestionar listas</string>
|
||||
<string name="error_list_load">Erro ao cargar as listas</string>
|
||||
<string name="error_missing_edits">O teu servidor sabe que a publicación foi editada, pero non ten unha copia das edición, polo que non pode mostrarchas.
|
||||
\n
|
||||
\nÉ un <a href="https://github.com/mastodon/mastodon/issues/25398">problema coñecido</a> en Mastodon.</string>
|
||||
|
@ -587,8 +586,6 @@
|
|||
<string name="title_tab_public_trending_hashtags">Cancelos</string>
|
||||
<string name="title_tab_public_trending_links">Ligazóns</string>
|
||||
<string name="title_tab_public_trending_statuses">Publicacións</string>
|
||||
<string name="title_lists_loading">Listas - cargando…</string>
|
||||
<string name="title_lists_failed">Listas - fallou a carga</string>
|
||||
<string name="manage_lists">Xestionar listas</string>
|
||||
<string name="announcement_date_updated">(Actualizada: %1$s)</string>
|
||||
<string name="pref_summary_content_filters">O servidor non é compatible cos filtros</string>
|
||||
|
@ -768,4 +765,4 @@
|
|||
<string name="pref_notification_fetch_ok_timestamp_fmt">✔ hai %1$s @ %2$s</string>
|
||||
<string name="pref_notification_fetch_err_timestamp_fmt">✖ hai %1$s @ %2$s</string>
|
||||
<string name="pref_notification_fetch_needs_push">A conta non ten o método «push». Pechar a sesión e volver acceder podería arranxalo.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -557,7 +557,6 @@
|
|||
<string name="post_media_image">Kép</string>
|
||||
<string name="select_list_empty">Még nincsenek listáid</string>
|
||||
<string name="select_list_manage">Listák kezelése</string>
|
||||
<string name="error_list_load">Hiba a listák betöltése során</string>
|
||||
<string name="pref_ui_text_size">UI betűméret</string>
|
||||
<string name="notification_listenable_worker_name">Háttértevékenység</string>
|
||||
<string name="notification_listenable_worker_description">Értesítések, amikor a Pachli a háttérben működik</string>
|
||||
|
@ -568,4 +567,4 @@
|
|||
<string name="error_missing_edits">A kiszolgálód tudja, hogy ezt a bejegyzést szerkesztették, de erről nincs másolata, így ezt nem tudjuk neked megmutatni.
|
||||
\n
|
||||
\nEz egy <a href="https://github.com/mastodon/mastodon/issues/25398">Mastodon hiba #25398</a>.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -549,7 +549,6 @@
|
|||
<string name="post_media_image">Mynd</string>
|
||||
<string name="select_list_empty">Þú ert ekki með neina lista</string>
|
||||
<string name="select_list_manage">Sýsla með lista</string>
|
||||
<string name="error_list_load">Villa við að hlaða inn listum</string>
|
||||
<string name="help_empty_home">Þetta er <b>tímalínan þín</b>. Hún sýnir nýlegar færslur þeirra sem þú fylgist með. \n \nTil að skoða hvað aðrir eru að gera getur þú til dæmis uppgötvað viðkomandi í einni af hinum tímalínunum. Til dæmis á staðværu tímalínu netþjónsins þíns [iconics gmd_group]. Eða að þú leitar að þeim eftir nafni [iconics gmd_search]; til dæmis geturðu leitað að Pachli til að finna Mastodon-aðganginn okkar.</string>
|
||||
<string name="pref_ui_text_size">Textastærð viðmóts</string>
|
||||
<string name="notification_listenable_worker_name">Bakgrunnsvirkni</string>
|
||||
|
@ -561,4 +560,4 @@
|
|||
<string name="error_missing_edits">Þjónninn þinn veit að þessari færslu hefur verið breytt, en er hins vegar ekki með afrit af breytingunum, þannig að ekki er hægt að sýna þér þær.
|
||||
\n
|
||||
\nÞetta er <a href="https://github.com/mastodon/mastodon/issues/25398">Mastodon verkbeiðni #25398</a>.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -573,7 +573,6 @@
|
|||
<string name="ui_success_rejected_follow_request">Richiesta di follow bloccata</string>
|
||||
<string name="select_list_empty">Non hai ancora alcuna lista</string>
|
||||
<string name="select_list_manage">Gestisci liste</string>
|
||||
<string name="error_list_load">Errore nel caricamento delle liste</string>
|
||||
<string name="status_filtered_show_anyway">Mostra comunque</string>
|
||||
<string name="status_filter_placeholder_label_format">Filtrato: <b>%1$s</b></string>
|
||||
<string name="pref_title_account_filter_keywords">Profili</string>
|
||||
|
@ -610,4 +609,4 @@
|
|||
<string name="title_tab_public_trending_statuses">Post</string>
|
||||
<string name="pref_update_notification_frequency_once_per_version">Una volta per versione</string>
|
||||
<string name="update_dialog_title">Un aggiornamento è disponibile</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -550,7 +550,6 @@
|
|||
<string name="filter_description_format">%s: %s</string>
|
||||
<string name="load_newest_notifications">最新の通知を読み込む</string>
|
||||
<string name="compose_delete_draft">下書きを削除しますか?</string>
|
||||
<string name="error_list_load">リストを読み込む際のエラー</string>
|
||||
<string name="error_missing_edits">あなたのサーバーは、この投稿が変更されたことを把握していますが、編集履歴のコピーを備えていないので、表示できません。
|
||||
\n
|
||||
\nこれは<a href="https://github.com/mastodon/mastodon/issues/25398">Mastodonのissue #25398</a>です。</string>
|
||||
|
@ -594,4 +593,4 @@
|
|||
<string name="action_translate">翻訳</string>
|
||||
<string name="update_dialog_title">アップデート可能です</string>
|
||||
<string name="action_translate_undo">翻訳を元に戻す</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -530,7 +530,6 @@
|
|||
<string name="action_continue_edit">Fortsett å redigere</string>
|
||||
<string name="unsaved_changes">Du har ulagrede endringer.</string>
|
||||
<string name="select_list_empty">Du har ingen lister, enda</string>
|
||||
<string name="error_list_load">Feil under lading av lister</string>
|
||||
<string name="select_list_manage">Forvalte lister</string>
|
||||
<string name="ui_error_reblog_fmt">Deling av innlegget feilet: %1$s</string>
|
||||
<string name="ui_error_favourite_fmt">Favorisering av innlegg feilet: %1$s</string>
|
||||
|
@ -572,14 +571,12 @@
|
|||
<string name="action_manage_tabs">Håndter faner</string>
|
||||
<string name="action_suggestions">Foreslåtte kontoer</string>
|
||||
<string name="confirmation_hashtag_muted">#%s skjult</string>
|
||||
<string name="title_lists_failed">Lister - kunne ikke lastes inn</string>
|
||||
<string name="manage_lists">Håndter lister</string>
|
||||
<string name="compose_schedule_date_time_fmt">%1$s %2$s</string>
|
||||
<string name="title_public_trending_links">Trendende lenker</string>
|
||||
<string name="title_tab_public_trending_hashtags">Emneknagger</string>
|
||||
<string name="title_tab_public_trending_links">Lenker</string>
|
||||
<string name="notification_severed_relationships_domain_block_body">En moderator suspenderte instansen</string>
|
||||
<string name="title_lists_loading">Lister - laster inn…</string>
|
||||
<string name="poll_show_votes">Vis stemminger</string>
|
||||
<string name="compose_warn_language_dialog_title">Kontroller språket til innlegget</string>
|
||||
<string name="compose_warn_language_dialog_fmt">Språket til innlegget er %1$s men det kan skje at du har skrevet innlegget på %2$s.</string>
|
||||
|
@ -770,4 +767,4 @@
|
|||
<string name="search_operator_where_dialog_all">Alle innlegg</string>
|
||||
<string name="search_operator_where_dialog_library_hint">Dine egne innlegg, fremhevinger, favoritmerker, bokmerker, og innlegg som @nevner deg</string>
|
||||
<string name="search_operator_where_dialog_public">Fødererte innlegg</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -522,7 +522,6 @@
|
|||
<string name="notification_unknown_name">Onbekend</string>
|
||||
<string name="ui_error_clear_notifications">Wissen meldingen mislukt: %s</string>
|
||||
<string name="ui_success_accepted_follow_request">Volgverzoek geaccepteerd</string>
|
||||
<string name="error_list_load">Fout bij laden lijsten</string>
|
||||
<string name="select_list_empty">Je hebt nog geen lijsten</string>
|
||||
<string name="select_list_manage">Lijsten beheren</string>
|
||||
<string name="pref_title_account_filter_keywords">Profielen</string>
|
||||
|
@ -607,4 +606,4 @@
|
|||
<string name="pref_update_check_no_updates">Er zijn geen updates beschikbaar</string>
|
||||
<string name="pref_update_next_scheduled_check">Volgende geplande controle: %1$s</string>
|
||||
<string name="pref_summary_content_filters">Je server ondersteund geen filters</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -550,7 +550,6 @@
|
|||
<string name="post_media_image">Imatge</string>
|
||||
<string name="select_list_empty">Avètz pas encara de lista</string>
|
||||
<string name="select_list_manage">Gerir las listas</string>
|
||||
<string name="error_list_load">Error en cargant las litas</string>
|
||||
<string name="ui_error_favourite_fmt">Fracàs de la mes en favorit : %1$s</string>
|
||||
<string name="ui_error_reblog_fmt">Fracàs en partejant : %1$s</string>
|
||||
<string name="ui_error_vote_fmt">Fracàs del vòt : %1$s</string>
|
||||
|
@ -568,4 +567,4 @@
|
|||
<string name="notification_listenable_worker_description">Notificacions quand Tuska s’executa en rèireplan</string>
|
||||
<string name="notification_notification_worker">Recuperacion de las notificacions…</string>
|
||||
<string name="notification_prune_cache">Manteniment del cache…</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -587,7 +587,6 @@
|
|||
<string name="notification_update_description">Notificações quando Toots com os quais interagiu são editados</string>
|
||||
<string name="notification_listenable_worker_description">Notificações quando Pachli está trabalhando em segundo plano</string>
|
||||
<string name="status_filtered_show_anyway">Mostrar mesmo assim</string>
|
||||
<string name="error_list_load">Erro ao carregar as listas</string>
|
||||
<string name="ui_error_accept_follow_request">Falha ao aceitar a solicitação de seguir: %s</string>
|
||||
<string name="ui_success_rejected_follow_request">Pedido para seguir bloqueado</string>
|
||||
<string name="notification_sign_up_format">%s se inscreveu</string>
|
||||
|
@ -613,4 +612,4 @@
|
|||
<string name="load_newest_notifications">Carregar notificações mais recentes</string>
|
||||
<string name="action_discard">Descartar mudanças</string>
|
||||
<string name="pref_ui_text_size">Tamanho do texto da UI</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -565,7 +565,6 @@
|
|||
<string name="ui_error_bookmark_fmt">Att bokmärka inlägg misslyckades: %1$s</string>
|
||||
<string name="ui_error_clear_notifications">Rensing av aviseringar misslyckades: %s</string>
|
||||
<string name="ui_error_favourite_fmt">Att favoritmarkera inlägg misslyckades: %1$s</string>
|
||||
<string name="error_list_load">Fel vid laddning av listor</string>
|
||||
<string name="ui_error_reject_follow_request">Avacceptera följarförgrågan misslyckades: %s</string>
|
||||
<string name="label_filter_context">Filtrera sammanhang</string>
|
||||
<string name="title_public_trending_hashtags">Populära hashtaggar</string>
|
||||
|
@ -610,4 +609,4 @@
|
|||
<string name="action_translate_undo">Ångra översättning</string>
|
||||
<string name="server_repository_error">Kunde inte hämta serverinformation för %1$s: %2$s</string>
|
||||
<string name="pref_summary_content_filters">Din server stöder inte filter</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -533,7 +533,6 @@
|
|||
<string name="description_browser_login">Ek kimlik doğrulama yöntemlerini destekleyebilir ancak desteklenen bir tarayıcı gerektirir.</string>
|
||||
<string name="select_list_empty">Henüz hiç listen yok</string>
|
||||
<string name="select_list_manage">Listeleri yönet</string>
|
||||
<string name="error_list_load">Listeler yüklenirken hata oluştu</string>
|
||||
<string name="action_share_account_link">Hesaba bağlantı paylaş</string>
|
||||
<string name="action_share_account_username">Hesap adını paylaş</string>
|
||||
<string name="account_username_copied">Kullanıcı adı kopyalandı</string>
|
||||
|
@ -569,4 +568,4 @@
|
|||
\n
|
||||
\nBu <a href="https://github.com/mastodon/mastodon/issues/25398">Mastodon sorununu #25398</a>.</string>
|
||||
<string name="error_media_playback">Oynatma başarısız oldu: %s</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -570,7 +570,6 @@
|
|||
<string name="help_empty_home">Це ваша <b>головна стрічка</b>. Вона показує останні дописи облікових записів, за якими ви стежите. \n \nЩоб переглянути облікові записи, ви можете знайти їх в одній з інших стрічок. Наприклад, на локальній стрічці вашого сервера [iconics gmd_group]. Або ви можете шукати їх за іменами [iconics gmd_search]; наприклад, шукайте Pachli, щоб знайти наш обліковий запис Mastodon.</string>
|
||||
<string name="hint_description">Опис</string>
|
||||
<string name="post_media_image">Зображення</string>
|
||||
<string name="error_list_load">Помилка завантаження списків</string>
|
||||
<string name="select_list_empty">У вас ще немає списків</string>
|
||||
<string name="select_list_manage">Керувати списками</string>
|
||||
<string name="pref_ui_text_size">Розмір шрифту інтерфейсу</string>
|
||||
|
@ -583,4 +582,4 @@
|
|||
<string name="error_missing_edits">Ваш сервер знає, що цей допис було змінено, але не має копії редагувань, тому вони не можуть бути вам показані.
|
||||
\n
|
||||
\nЦе <a href="https://github.com/mastodon/mastodon/issues/25398">помилка #25398 у Mastodon</a>.</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -539,7 +539,6 @@
|
|||
<string name="hint_description">Mô tả</string>
|
||||
<string name="post_media_image">Hình ảnh</string>
|
||||
<string name="select_list_manage">Quản lý danh sách</string>
|
||||
<string name="error_list_load">Xảy ra lỗi khi tải danh sách</string>
|
||||
<string name="select_list_empty">Bạn chưa có danh sách</string>
|
||||
<string name="load_newest_notifications">Tải những thông báo mới nhất</string>
|
||||
<string name="compose_delete_draft">Xóa bản nháp\?</string>
|
||||
|
@ -554,4 +553,4 @@
|
|||
<string name="error_media_playback">Không thể phát: %s</string>
|
||||
<string name="dialog_delete_filter_text">Xóa bộ lọc \'%1$s\'\?</string>
|
||||
<string name="dialog_delete_filter_positive_action">Xóa</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -553,7 +553,6 @@
|
|||
<string name="post_media_image">图片</string>
|
||||
<string name="hint_description">描述</string>
|
||||
<string name="select_list_manage">管理列表</string>
|
||||
<string name="error_list_load">加载列表出错</string>
|
||||
<string name="select_list_empty">你还没有列表</string>
|
||||
<string name="load_newest_notifications">加载最新通知</string>
|
||||
<string name="compose_delete_draft">删除草稿?</string>
|
||||
|
@ -568,4 +567,4 @@
|
|||
<string name="error_media_playback">播放失败了:%s</string>
|
||||
<string name="dialog_delete_filter_text">删除筛选器\'%1$s\'吗?</string>
|
||||
<string name="dialog_delete_filter_positive_action">删除</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -379,8 +379,6 @@
|
|||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="add_account_name">Add Account</string>
|
||||
<string name="add_account_description">Add new Mastodon Account</string>
|
||||
<string name="title_lists_loading">Lists - loading…</string>
|
||||
<string name="title_lists_failed">Lists - failed to load</string>
|
||||
<string name="manage_lists">Manage lists</string>
|
||||
<string name="compose_active_account_description">Posting as %1$s</string>
|
||||
<plurals name="hint_describe_for_visually_impaired">
|
||||
|
@ -487,7 +485,6 @@
|
|||
<string name="select_list_title">Select list</string>
|
||||
<string name="select_list_empty">You have no lists, yet</string>
|
||||
<string name="select_list_manage">Manage lists</string>
|
||||
<string name="error_list_load">Error loading lists</string>
|
||||
<string name="list">List</string>
|
||||
<string name="notifications_clear">Delete notifications</string>
|
||||
<string name="notifications_apply_filter">Filter notifications</string>
|
||||
|
@ -854,4 +851,7 @@
|
|||
|
||||
<string name="upload_failed_msg_fmt">The upload will be retried when you send the post. If it fails again the post will be saved in your drafts.\n\nThe error was: %1$s</string>
|
||||
<string name="upload_failed_modify_attachment">Modify attachment</string>
|
||||
<string name="main_viewmodel_error_set_active_account">Trying to log in failed with the following error:\n\n%1$s</string>
|
||||
<string name="main_viewmodel_error_refresh_account">Refreshing account failed with the following error:\n\n%1$s\n\nYou can continue, but your lists and filters may be incomplete.</string>
|
||||
<string name="action_relogin">Re-login</string>
|
||||
</resources>
|
||||
|
|
|
@ -39,8 +39,8 @@ import app.pachli.core.network.model.Notification
|
|||
import app.pachli.core.network.model.TimelineAccount
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.testing.rules.lazyActivityScenarioRule
|
||||
import app.pachli.core.testing.success
|
||||
import app.pachli.db.DraftsAlert
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.CustomTestApplication
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
|
@ -48,6 +48,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
|||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
|
@ -112,22 +113,21 @@ class MainActivityTest {
|
|||
val draftsAlert: DraftsAlert = mock()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setup() = runTest {
|
||||
hilt.inject()
|
||||
|
||||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account)
|
||||
onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList())
|
||||
onBlocking { accountVerifyCredentials() } doReturn success(account)
|
||||
onBlocking { listAnnouncements(false) } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
accountManager.addAccount(
|
||||
accountManager.verifyAndAddAccount(
|
||||
accessToken = "token",
|
||||
domain = "domain.example",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
oauthScopes = "scopes",
|
||||
newAccount = account,
|
||||
)
|
||||
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||
|
@ -152,7 +152,7 @@ class MainActivityTest {
|
|||
Notification.Type.FOLLOW,
|
||||
)
|
||||
rule.launch(intent)
|
||||
rule.getScenario().onActivity {
|
||||
rule.scenario.onActivity {
|
||||
val currentTab = it.findViewById<ViewPager2>(R.id.viewPager).currentItem
|
||||
val notificationTab = defaultTabs().indexOfFirst { it is Timeline.Notifications }
|
||||
assertEquals(currentTab, notificationTab)
|
||||
|
@ -169,7 +169,7 @@ class MainActivityTest {
|
|||
)
|
||||
|
||||
rule.launch(intent)
|
||||
rule.getScenario().onActivity {
|
||||
rule.scenario.onActivity {
|
||||
val nextActivity = shadowOf(it).peekNextStartedActivity()
|
||||
assertNotNull(nextActivity)
|
||||
assertEquals(
|
||||
|
|
|
@ -32,9 +32,18 @@ import app.pachli.core.network.model.Account
|
|||
import app.pachli.core.network.model.InstanceConfiguration
|
||||
import app.pachli.core.network.model.InstanceV1
|
||||
import app.pachli.core.network.model.SearchResult
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.network.retrofit.NodeInfoApi
|
||||
import app.pachli.core.testing.failure
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.core.testing.rules.lazyActivityScenarioRule
|
||||
import app.pachli.core.testing.success
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.github.michaelbull.result.andThen
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.testing.CustomTestApplication
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
|
@ -42,6 +51,9 @@ import java.time.Instant
|
|||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.properties.Delegates
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
@ -71,7 +83,12 @@ class ComposeActivityTest {
|
|||
@get:Rule(order = 0)
|
||||
var hilt = HiltAndroidRule(this)
|
||||
|
||||
val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val mainCoroutineRule = MainCoroutineRule(dispatcher)
|
||||
|
||||
@get:Rule(order = 2)
|
||||
var rule = lazyActivityScenarioRule<ComposeActivity>(
|
||||
launchActivity = false,
|
||||
)
|
||||
|
@ -81,67 +98,126 @@ class ComposeActivityTest {
|
|||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var nodeInfoApi: NodeInfoApi
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var instanceInfoRepository: InstanceInfoRepository
|
||||
|
||||
private var pachliAccountId by Delegates.notNull<Long>()
|
||||
|
||||
val account = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setup() = runTest {
|
||||
hilt.inject()
|
||||
|
||||
getInstanceCallback = null
|
||||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList())
|
||||
onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account)
|
||||
onBlocking { getCustomEmojis() } doReturn success(emptyList())
|
||||
onBlocking { getInstanceV2() } doReturn failure()
|
||||
onBlocking { getInstanceV1() } doAnswer {
|
||||
getInstanceCallback?.invoke().let { instance ->
|
||||
if (instance == null) {
|
||||
NetworkResult.failure(Throwable())
|
||||
failure()
|
||||
} else {
|
||||
NetworkResult.success(instance)
|
||||
success(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
onBlocking { search(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn NetworkResult.success(
|
||||
SearchResult(emptyList(), emptyList(), emptyList()),
|
||||
)
|
||||
onBlocking { getLists() } doReturn success(emptyList())
|
||||
onBlocking { listAnnouncements(any()) } doReturn success(emptyList())
|
||||
onBlocking { getContentFiltersV1() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
accountManager.addAccount(
|
||||
reset(nodeInfoApi)
|
||||
nodeInfoApi.stub {
|
||||
onBlocking { nodeInfoJrd() } doReturn success(
|
||||
UnvalidatedJrd(
|
||||
listOf(
|
||||
UnvalidatedJrd.Link(
|
||||
"http://nodeinfo.diaspora.software/ns/schema/2.1",
|
||||
"https://example.com",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
onBlocking { nodeInfo(any()) } doReturn success(
|
||||
UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")),
|
||||
)
|
||||
}
|
||||
|
||||
pachliAccountId = accountManager.verifyAndAddAccount(
|
||||
accessToken = "token",
|
||||
domain = "domain.example",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
oauthScopes = "scopes",
|
||||
newAccount = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
),
|
||||
)
|
||||
.andThen { accountManager.setActiveAccount(it) }
|
||||
.onSuccess { accountManager.refresh(it) }
|
||||
.get()!!.id
|
||||
}
|
||||
|
||||
/**
|
||||
* When tests do something like this (lines marked "->")
|
||||
*
|
||||
* fun whenBackButtonPressedNotEmpty_notFinish() = runTest {
|
||||
* rule.launch(intent())
|
||||
* -> dispatcher.scheduler.advanceUntilIdle()
|
||||
* -> accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
*
|
||||
* rule.scenario.onActivity {
|
||||
* -> dispatcher.scheduler.advanceUntilIdle()
|
||||
* insertSomeTextInContent(it)
|
||||
* clickBack(it)
|
||||
* assertFalse(it.isFinishing)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* it's because there's (currently) no easy way for the test to determine
|
||||
* that ComposeActivity has finished setting up the UI / loading data from
|
||||
* AccountManager and is ready to receive input.
|
||||
*
|
||||
* TODO: Fix this bug by rewriting ComposeViewModel to drive the UI
|
||||
* state of ComposeActivity, and waiting for ComposeViewModel to be
|
||||
* ready in tests.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun whenCloseButtonPressedAndEmpty_finish() {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
rule.scenario.onActivity {
|
||||
clickUp(it)
|
||||
assertTrue(it.isFinishing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenCloseButtonPressedNotEmpty_notFinish() {
|
||||
fun whenCloseButtonPressedNotEmpty_notFinish() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it)
|
||||
clickUp(it)
|
||||
assertFalse(it.isFinishing)
|
||||
|
@ -150,9 +226,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenModifiedInitialState_andCloseButtonPressed_notFinish() {
|
||||
fun whenModifiedInitialState_andCloseButtonPressed_notFinish() = runTest {
|
||||
rule.launch(intent(ComposeOptions(modifiedInitialState = true)))
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
clickUp(it)
|
||||
assertFalse(it.isFinishing)
|
||||
}
|
||||
|
@ -161,27 +240,33 @@ class ComposeActivityTest {
|
|||
@Test
|
||||
fun whenBackButtonPressedAndEmpty_finish() {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
rule.scenario.onActivity {
|
||||
clickBack(it)
|
||||
assertTrue(it.isFinishing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenBackButtonPressedNotEmpty_notFinish() {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
fun whenBackButtonPressedNotEmpty_notFinish() = runTest {
|
||||
rule.launch(intent())
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it)
|
||||
clickBack(it)
|
||||
assertFalse(it.isFinishing)
|
||||
// We would like to check for dialog but Robolectric doesn't work with AppCompat v7 yet
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenModifiedInitialState_andBackButtonPressed_notFinish() {
|
||||
fun whenModifiedInitialState_andBackButtonPressed_notFinish() = runTest {
|
||||
rule.launch(intent(ComposeOptions(modifiedInitialState = true)))
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
clickBack(it)
|
||||
assertFalse(it.isFinishing)
|
||||
}
|
||||
|
@ -191,7 +276,7 @@ class ComposeActivityTest {
|
|||
fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() = runTest {
|
||||
getInstanceCallback = { getInstanceWithCustomConfiguration(null) }
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
rule.scenario.onActivity {
|
||||
assertEquals(DEFAULT_CHARACTER_LIMIT, it.maximumTootCharacters)
|
||||
}
|
||||
}
|
||||
|
@ -203,7 +288,10 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(customMaximum, it.maximumTootCharacters)
|
||||
}
|
||||
}
|
||||
|
@ -215,7 +303,10 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(customMaximum, it.maximumTootCharacters)
|
||||
}
|
||||
}
|
||||
|
@ -227,7 +318,10 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(customMaximum, it.maximumTootCharacters)
|
||||
}
|
||||
}
|
||||
|
@ -239,67 +333,88 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(customMaximum * 2, it.maximumTootCharacters)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsNoUrl_everyCharacterIsCounted() {
|
||||
fun whenTextContainsNoUrl_everyCharacterIsCounted() = runTest {
|
||||
val content = "This is test content please ignore thx "
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, content)
|
||||
assertEquals(content.length, it.viewModel.statusLength.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() {
|
||||
fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() = runTest {
|
||||
val content = "Test 😜"
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, content)
|
||||
assertEquals(6, it.viewModel.statusLength.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsConesecutiveEmoji_emojisAreCountedAsSeparateCharacters() {
|
||||
fun whenTextContainsConesecutiveEmoji_emojisAreCountedAsSeparateCharacters() = runTest {
|
||||
val content = "Test 😜😜"
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, content)
|
||||
assertEquals(7, it.viewModel.statusLength.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() {
|
||||
fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() = runTest {
|
||||
val content = "https://🤪.com"
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, content)
|
||||
assertEquals(DEFAULT_CHARACTERS_RESERVED_PER_URL, it.viewModel.statusLength.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsNonEnglishCharacters_lengthIsCountedCorrectly() {
|
||||
fun whenTextContainsNonEnglishCharacters_lengthIsCountedCorrectly() = runTest {
|
||||
val content = "こんにちは. General Kenobi" // "Hello there. General Kenobi"
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, content)
|
||||
assertEquals(21, it.viewModel.statusLength.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() {
|
||||
fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() = runTest {
|
||||
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM%3A"
|
||||
val additionalContent = "Check out this @image #search result: "
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, additionalContent + url)
|
||||
assertEquals(
|
||||
additionalContent.length + DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||
|
@ -309,12 +424,15 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsShortUrls_allUrlsGetEllipsized() {
|
||||
fun whenTextContainsShortUrls_allUrlsGetEllipsized() = runTest {
|
||||
val shortUrl = "https://pachli.app"
|
||||
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM%3A"
|
||||
val additionalContent = " Check out this @image #search result: "
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, shortUrl + additionalContent + url)
|
||||
assertEquals(
|
||||
additionalContent.length + (DEFAULT_CHARACTERS_RESERVED_PER_URL * 2),
|
||||
|
@ -324,11 +442,14 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenTextContainsMultipleURLs_allURLsGetEllipsized() {
|
||||
fun whenTextContainsMultipleURLs_allURLsGetEllipsized() = runTest {
|
||||
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM%3A"
|
||||
val additionalContent = " Check out this @image #search result: "
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, url + additionalContent + url)
|
||||
assertEquals(
|
||||
additionalContent.length + (DEFAULT_CHARACTERS_RESERVED_PER_URL * 2),
|
||||
|
@ -346,7 +467,10 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, additionalContent + url)
|
||||
assertEquals(
|
||||
additionalContent.length + customUrlLength,
|
||||
|
@ -365,7 +489,10 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, shortUrl + additionalContent + url)
|
||||
assertEquals(
|
||||
additionalContent.length + (customUrlLength * 2),
|
||||
|
@ -383,7 +510,10 @@ class ComposeActivityTest {
|
|||
instanceInfoRepository.reload(accountManager.activeAccount)
|
||||
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
insertSomeTextInContent(it, url + additionalContent + url)
|
||||
assertEquals(
|
||||
additionalContent.length + (customUrlLength * 2),
|
||||
|
@ -393,9 +523,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionIsEmpty_specialTextIsInsertedAtCaret() {
|
||||
fun whenSelectionIsEmpty_specialTextIsInsertedAtCaret() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
editor.setText("Some text")
|
||||
|
@ -418,9 +551,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionDoesNotIncludeWordBreak_noSpecialTextIsInserted() {
|
||||
fun whenSelectionDoesNotIncludeWordBreak_noSpecialTextIsInserted() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = "Some text"
|
||||
|
@ -438,9 +574,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionIncludesWordBreaks_startsOfAllWordsArePrepended() {
|
||||
fun whenSelectionIncludesWordBreaks_startsOfAllWordsArePrepended() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = "one two three four"
|
||||
|
@ -461,9 +600,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionIncludesEnd_textIsNotAppended() {
|
||||
fun whenSelectionIncludesEnd_textIsNotAppended() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = "Some text"
|
||||
|
@ -481,9 +623,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionIncludesStartAndStartIsAWord_textIsPrepended() {
|
||||
fun whenSelectionIncludesStartAndStartIsAWord_textIsPrepended() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = "Some text"
|
||||
|
@ -503,9 +648,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionIncludesStartAndStartIsNotAWord_textIsNotPrepended() {
|
||||
fun whenSelectionIncludesStartAndStartIsNotAWord_textIsNotPrepended() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = " Some text"
|
||||
|
@ -523,9 +671,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionBeginsAtWordStart_textIsPrepended() {
|
||||
fun whenSelectionBeginsAtWordStart_textIsPrepended() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = "Some text"
|
||||
|
@ -545,9 +696,12 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenSelectionEndsAtWordStart_textIsAppended() {
|
||||
fun whenSelectionEndsAtWordStart_textIsAppended() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
val editor = it.findViewById<EditText>(R.id.composeEditField)
|
||||
val insertText = "#"
|
||||
val originalText = "Some text"
|
||||
|
@ -567,42 +721,55 @@ class ComposeActivityTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun whenNoLanguageIsGiven_defaultLanguageIsSelected() {
|
||||
fun whenNoLanguageIsGiven_defaultLanguageIsSelected() = runTest {
|
||||
rule.launch()
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals(Locale.getDefault().language, it.selectedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun languageGivenInComposeOptionsIsRespected() {
|
||||
fun languageGivenInComposeOptionsIsRespected() = runTest {
|
||||
rule.launch(intent(ComposeOptions(language = "no")))
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("no", it.selectedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modernLanguageCodeIsUsed() {
|
||||
fun modernLanguageCodeIsUsed() = runTest {
|
||||
// https://github.com/tuskyapp/Tusky/issues/2903
|
||||
// "ji" was deprecated in favor of "yi"
|
||||
rule.launch(intent(ComposeOptions(language = "ji")))
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("yi", it.selectedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownLanguageGivenInComposeOptionsIsRespected() {
|
||||
fun unknownLanguageGivenInComposeOptionsIsRespected() = runTest {
|
||||
rule.launch(intent(ComposeOptions(language = "zzz")))
|
||||
rule.getScenario().onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
accountManager.getPachliAccountFlow(pachliAccountId).first()
|
||||
rule.scenario.onActivity {
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertEquals("zzz", it.selectedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns an intent to launch [ComposeActivity] with the given options */
|
||||
private fun intent(composeOptions: ComposeOptions) = ComposeActivityIntent(
|
||||
private fun intent(composeOptions: ComposeOptions? = null) = ComposeActivityIntent(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
pachliAccountId,
|
||||
composeOptions,
|
||||
)
|
||||
|
||||
|
|
|
@ -18,51 +18,91 @@
|
|||
package app.pachli.components.notifications
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.pachli.PachliApplication
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.data.repository.AccountPreferenceDataStore
|
||||
import app.pachli.core.data.repository.ContentFilters
|
||||
import app.pachli.core.data.repository.ContentFiltersError
|
||||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.database.dao.AccountDao
|
||||
import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.network.retrofit.NodeInfoApi
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.testing.fakes.InMemorySharedPreferences
|
||||
import app.pachli.core.testing.failure
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.core.testing.success
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import app.pachli.util.HiltTestApplication_Application
|
||||
import com.github.michaelbull.result.andThen
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.testing.CustomTestApplication
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlin.properties.Delegates
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.mockito.kotlin.reset
|
||||
import org.mockito.kotlin.stub
|
||||
import org.robolectric.annotation.Config
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
open class PachliHiltApplication : PachliApplication()
|
||||
|
||||
@CustomTestApplication(PachliHiltApplication::class)
|
||||
interface HiltTestApplication
|
||||
|
||||
@HiltAndroidTest
|
||||
@Config(application = HiltTestApplication_Application::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
abstract class NotificationsViewModelTestBase {
|
||||
protected lateinit var notificationsRepository: NotificationsRepository
|
||||
protected lateinit var sharedPreferencesRepository: SharedPreferencesRepository
|
||||
protected lateinit var accountManager: AccountManager
|
||||
@get:Rule(order = 0)
|
||||
var hilt = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var nodeInfoApi: NodeInfoApi
|
||||
|
||||
@Inject
|
||||
lateinit var sharedPreferencesRepository: SharedPreferencesRepository
|
||||
|
||||
@Inject
|
||||
lateinit var contentFiltersRepository: ContentFiltersRepository
|
||||
|
||||
@Inject
|
||||
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var accountDao: AccountDao
|
||||
|
||||
protected val notificationsRepository: NotificationsRepository = mock()
|
||||
protected lateinit var timelineCases: TimelineCases
|
||||
protected lateinit var viewModel: NotificationsViewModel
|
||||
private lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
|
||||
private lateinit var contentFiltersRepository: ContentFiltersRepository
|
||||
|
||||
private val eventHub = EventHub()
|
||||
|
||||
|
@ -77,53 +117,39 @@ abstract class NotificationsViewModelTestBase {
|
|||
/** Exception to throw when testing errors */
|
||||
protected val httpException = HttpException(emptyError)
|
||||
|
||||
@get:Rule
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
private val account = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
)
|
||||
|
||||
protected var pachliAccountId by Delegates.notNull<Long>()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
notificationsRepository = mock()
|
||||
fun setup() = runTest {
|
||||
hilt.inject()
|
||||
|
||||
val defaultAccount = AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.test",
|
||||
accessToken = "fakeToken",
|
||||
clientId = "fakeId",
|
||||
clientSecret = "fakeSecret",
|
||||
isActive = true,
|
||||
notificationsFilter = "['follow']",
|
||||
)
|
||||
reset(notificationsRepository)
|
||||
|
||||
val activeAccountFlow = MutableStateFlow(defaultAccount)
|
||||
|
||||
accountManager = mock {
|
||||
on { activeAccount } doReturn defaultAccount
|
||||
whenever(it.activeAccountFlow).thenReturn(activeAccountFlow)
|
||||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account)
|
||||
onBlocking { getInstanceV2(anyOrNull()) } doReturn success(DEFAULT_INSTANCE_V2)
|
||||
onBlocking { getLists() } doReturn success(emptyList())
|
||||
onBlocking { getCustomEmojis() } doReturn failure()
|
||||
onBlocking { getContentFilters() } doReturn success(emptyList())
|
||||
onBlocking { listAnnouncements(anyOrNull()) } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
accountPreferenceDataStore = AccountPreferenceDataStore(
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
timelineCases = mock()
|
||||
|
||||
contentFiltersRepository = mock {
|
||||
whenever(it.contentFilters).thenReturn(MutableStateFlow<Result<ContentFilters?, ContentFiltersError.GetContentFiltersError>>(Ok(null)))
|
||||
}
|
||||
|
||||
sharedPreferencesRepository = SharedPreferencesRepository(
|
||||
InMemorySharedPreferences(),
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
val mastodonApi: MastodonApi = mock {
|
||||
onBlocking { getInstanceV2() } doAnswer { null }
|
||||
onBlocking { getInstanceV1() } doAnswer { null }
|
||||
}
|
||||
|
||||
val nodeInfoApi: NodeInfoApi = mock {
|
||||
onBlocking { nodeInfoJrd() } doReturn NetworkResult.success(
|
||||
reset(nodeInfoApi)
|
||||
nodeInfoApi.stub {
|
||||
onBlocking { nodeInfoJrd() } doReturn success(
|
||||
UnvalidatedJrd(
|
||||
listOf(
|
||||
UnvalidatedJrd.Link(
|
||||
|
@ -133,35 +159,40 @@ abstract class NotificationsViewModelTestBase {
|
|||
),
|
||||
),
|
||||
)
|
||||
onBlocking { nodeInfo(any()) } doReturn NetworkResult.success(
|
||||
onBlocking { nodeInfo(any()) } doReturn success(
|
||||
UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")),
|
||||
)
|
||||
}
|
||||
|
||||
val serverRepository = ServerRepository(
|
||||
mastodonApi,
|
||||
nodeInfoApi,
|
||||
pachliAccountId = accountManager.verifyAndAddAccount(
|
||||
accessToken = "token",
|
||||
domain = "domain.example",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
oauthScopes = "scopes",
|
||||
)
|
||||
.andThen {
|
||||
accountManager.setNotificationsFilter(it, "['follow']")
|
||||
accountManager.setActiveAccount(it)
|
||||
}
|
||||
.onSuccess { accountManager.refresh(it) }
|
||||
.get()!!.id
|
||||
|
||||
accountPreferenceDataStore = AccountPreferenceDataStore(
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
statusDisplayOptionsRepository = StatusDisplayOptionsRepository(
|
||||
sharedPreferencesRepository,
|
||||
serverRepository,
|
||||
accountManager,
|
||||
accountPreferenceDataStore,
|
||||
TestScope(),
|
||||
)
|
||||
timelineCases = mock()
|
||||
|
||||
viewModel = NotificationsViewModel(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
notificationsRepository,
|
||||
accountManager,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
contentFiltersRepository,
|
||||
statusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository,
|
||||
pachliAccountId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ package app.pachli.components.notifications
|
|||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
|
@ -33,6 +34,7 @@ import org.mockito.kotlin.verify
|
|||
* This is only tested in the success case; if it passed there it must also
|
||||
* have passed in the error case.
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() {
|
||||
@Test
|
||||
fun `clearing notifications succeeds && invalidate the repository`() = runTest {
|
||||
|
|
|
@ -18,46 +18,35 @@
|
|||
package app.pachli.components.notifications
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.network.model.Notification
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
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?
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestContentFilter : 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 {
|
||||
// Given
|
||||
// - Initial filter is from the active account
|
||||
// (skip the first item, the default state)
|
||||
awaitItem()
|
||||
assertThat(awaitItem().activeFilter)
|
||||
.containsExactlyElementsIn(setOf(Notification.Type.FOLLOW))
|
||||
|
||||
// When
|
||||
viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG)))
|
||||
// - Updating the filter
|
||||
viewModel.accept(InfallibleUiAction.ApplyFilter(pachliAccountId, 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)
|
||||
assertThat(awaitItem().activeFilter)
|
||||
.containsExactlyElementsIn(setOf(Notification.Type.REBLOG))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import app.cash.turbine.test
|
|||
import app.pachli.core.network.model.Relationship
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
|
@ -38,6 +39,7 @@ import org.mockito.kotlin.verify
|
|||
* This is only tested in the success case; if it passed there it must also
|
||||
* have passed in the error case.
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestNotificationFilterAction : NotificationsViewModelTestBase() {
|
||||
/** Dummy relationship */
|
||||
private val relationship = Relationship(
|
||||
|
|
|
@ -22,6 +22,7 @@ import app.cash.turbine.test
|
|||
import app.pachli.core.data.model.StatusDisplayOptions
|
||||
import app.pachli.core.preferences.PrefKeys
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
@ -31,6 +32,7 @@ import org.junit.Test
|
|||
* - Is the initial value taken from values in sharedPreferences and account?
|
||||
* - Is the correct update emitted when a relevant preference changes?
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() {
|
||||
|
||||
private val defaultStatusDisplayOptions = StatusDisplayOptions()
|
||||
|
|
|
@ -23,6 +23,7 @@ import app.pachli.core.database.model.TranslationState
|
|||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
|
@ -40,6 +41,7 @@ import org.mockito.kotlin.verify
|
|||
* This is only tested in the success case; if it passed there it must also
|
||||
* have passed in the error case.
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() {
|
||||
private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3"))
|
||||
private val statusViewData = StatusViewData(
|
||||
|
|
|
@ -23,6 +23,7 @@ import app.pachli.core.network.model.Notification
|
|||
import app.pachli.core.preferences.PrefKeys
|
||||
import app.pachli.core.preferences.TabTapBehaviour
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
@ -32,6 +33,7 @@ import org.junit.Test
|
|||
* - Is the initial value taken from values in sharedPreferences and account?
|
||||
* - Is the correct update emitted when a relevant preference changes?
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() {
|
||||
|
||||
private val initialUiState = UiState(
|
||||
|
@ -43,7 +45,9 @@ class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() {
|
|||
@Test
|
||||
fun `should load initial filter from active account`() = runTest {
|
||||
viewModel.uiState.test {
|
||||
assertThat(expectMostRecentItem()).isEqualTo(initialUiState)
|
||||
// skip initial empty UiState
|
||||
awaitItem()
|
||||
assertThat(awaitItem()).isEqualTo(initialUiState)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,25 +17,26 @@
|
|||
|
||||
package app.pachli.components.notifications
|
||||
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.argumentCaptor
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
@HiltAndroidTest
|
||||
class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() {
|
||||
|
||||
@Test
|
||||
fun `should save notification ID to active account`() = runTest {
|
||||
argumentCaptor<AccountEntity>().apply {
|
||||
viewModel.accountFlow.test {
|
||||
// Given
|
||||
assertThat(awaitItem().entity.lastNotificationId).isEqualTo("0")
|
||||
|
||||
// When
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, "1234"))
|
||||
|
||||
// Then
|
||||
verify(accountManager).saveAccount(capture())
|
||||
assertThat(this.lastValue.lastNotificationId)
|
||||
.isEqualTo("1234")
|
||||
assertThat(awaitItem().entity.lastNotificationId).isEqualTo("1234")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@ package app.pachli.components.timeline
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.pachli.PachliApplication
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
|
@ -28,16 +27,19 @@ import app.pachli.core.data.repository.AccountManager
|
|||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.network.retrofit.NodeInfoApi
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.testing.failure
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.core.testing.success
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.github.michaelbull.result.andThen
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.testing.CustomTestApplication
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
|
@ -45,12 +47,14 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
|||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
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.mockito.kotlin.reset
|
||||
|
@ -109,19 +113,36 @@ abstract class CachedTimelineViewModelTestBase {
|
|||
/** Exception to throw when testing errors */
|
||||
protected val httpException = HttpException(emptyError)
|
||||
|
||||
val account = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setup() = runTest {
|
||||
hilt.inject()
|
||||
|
||||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception())
|
||||
onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account)
|
||||
onBlocking { getInstanceV2() } doReturn success(DEFAULT_INSTANCE_V2)
|
||||
onBlocking { getLists() } doReturn success(emptyList())
|
||||
onBlocking { getCustomEmojis() } doReturn failure()
|
||||
onBlocking { getContentFilters() } doReturn success(emptyList())
|
||||
onBlocking { listAnnouncements(any()) } doReturn success(emptyList())
|
||||
onBlocking { getContentFiltersV1() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
reset(nodeInfoApi)
|
||||
nodeInfoApi.stub {
|
||||
onBlocking { nodeInfoJrd() } doReturn NetworkResult.success(
|
||||
onBlocking { nodeInfoJrd() } doReturn success(
|
||||
UnvalidatedJrd(
|
||||
listOf(
|
||||
UnvalidatedJrd.Link(
|
||||
|
@ -131,39 +152,28 @@ abstract class CachedTimelineViewModelTestBase {
|
|||
),
|
||||
),
|
||||
)
|
||||
onBlocking { nodeInfo(any()) } doReturn NetworkResult.success(
|
||||
onBlocking { nodeInfo(any()) } doReturn success(
|
||||
UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")),
|
||||
)
|
||||
}
|
||||
|
||||
accountManager.addAccount(
|
||||
accountManager.verifyAndAddAccount(
|
||||
accessToken = "token",
|
||||
domain = "domain.example",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
oauthScopes = "scopes",
|
||||
newAccount = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
),
|
||||
)
|
||||
.andThen { accountManager.setActiveAccount(it) }
|
||||
.onSuccess { accountManager.refresh(it) }
|
||||
|
||||
timelineCases = mock()
|
||||
|
||||
viewModel = CachedTimelineViewModel(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Home)),
|
||||
cachedTimelineRepository,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
contentFiltersRepository,
|
||||
accountManager,
|
||||
statusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository,
|
||||
|
|
|
@ -17,10 +17,16 @@
|
|||
|
||||
package app.pachli.components.timeline
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.components.timeline.viewmodel.InfallibleUiAction
|
||||
import app.pachli.core.data.repository.Loadable
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.Timeline
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
@ -29,16 +35,21 @@ class CachedTimelineViewModelTestVisibleId : CachedTimelineViewModelTestBase() {
|
|||
|
||||
@Test
|
||||
fun `should save status ID to active account`() = runTest {
|
||||
// Given
|
||||
assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId)
|
||||
.isNull()
|
||||
assertThat(viewModel.timeline).isEqualTo(Timeline.Home)
|
||||
|
||||
// When
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
|
||||
accountManager
|
||||
.activeAccountFlow.filterIsInstance<Loadable.Loaded<AccountEntity?>>()
|
||||
.filter { it.data != null }
|
||||
.map { it.data }
|
||||
.test {
|
||||
// Given
|
||||
assertThat(expectMostRecentItem()!!.lastVisibleHomeTimelineStatusId).isNull()
|
||||
|
||||
// Then
|
||||
assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId)
|
||||
.isEqualTo("1234")
|
||||
// When
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId("1234"))
|
||||
|
||||
// Then
|
||||
assertThat(awaitItem()!!.lastVisibleHomeTimelineStatusId).isEqualTo("1234")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||
import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator
|
||||
import app.pachli.components.timeline.viewmodel.Page
|
||||
import app.pachli.components.timeline.viewmodel.PageCache
|
||||
import app.pachli.core.data.repository.AccountManager
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Status
|
||||
|
@ -51,16 +50,14 @@ import retrofit2.Response
|
|||
@Config(sdk = [29])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NetworkTimelineRemoteMediatorTest {
|
||||
private val accountManager: AccountManager = mock {
|
||||
on { activeAccount } doReturn AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.example",
|
||||
accessToken = "token",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
isActive = true,
|
||||
)
|
||||
}
|
||||
private val activeAccount = AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.example",
|
||||
accessToken = "token",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
isActive = true,
|
||||
)
|
||||
|
||||
private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<String, Status>
|
||||
|
||||
|
@ -74,9 +71,8 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
fun `should return error when network call returns error code`() = runTest {
|
||||
// Given
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(
|
||||
viewModelScope = this,
|
||||
api = mock(defaultAnswer = { Response.error<String>(500, "".toResponseBody()) }),
|
||||
accountManager = accountManager,
|
||||
activeAccount = activeAccount,
|
||||
factory = pagingSourceFactory,
|
||||
pageCache = PageCache(),
|
||||
timeline = Timeline.Home,
|
||||
|
@ -96,9 +92,8 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
fun `should return error when network call fails`() = runTest {
|
||||
// Given
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(
|
||||
viewModelScope = this,
|
||||
api = mock(defaultAnswer = { throw IOException() }),
|
||||
accountManager,
|
||||
activeAccount = activeAccount,
|
||||
factory = pagingSourceFactory,
|
||||
pageCache = PageCache(),
|
||||
timeline = Timeline.Home,
|
||||
|
@ -118,7 +113,6 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
// Given
|
||||
val pages = PageCache()
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(
|
||||
viewModelScope = this,
|
||||
api = mock {
|
||||
onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success(
|
||||
listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")),
|
||||
|
@ -128,7 +122,7 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
),
|
||||
)
|
||||
},
|
||||
accountManager = accountManager,
|
||||
activeAccount = activeAccount,
|
||||
factory = pagingSourceFactory,
|
||||
pageCache = pages,
|
||||
timeline = Timeline.Home,
|
||||
|
@ -183,7 +177,6 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(
|
||||
viewModelScope = this,
|
||||
api = mock {
|
||||
onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success(
|
||||
listOf(mockStatus("10"), mockStatus("9"), mockStatus("8")),
|
||||
|
@ -193,7 +186,7 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
),
|
||||
)
|
||||
},
|
||||
accountManager = accountManager,
|
||||
activeAccount = activeAccount,
|
||||
factory = pagingSourceFactory,
|
||||
pageCache = pages,
|
||||
timeline = Timeline.Home,
|
||||
|
@ -256,7 +249,6 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
}
|
||||
|
||||
val remoteMediator = NetworkTimelineRemoteMediator(
|
||||
viewModelScope = this,
|
||||
api = mock {
|
||||
onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success(
|
||||
listOf(mockStatus("4"), mockStatus("3"), mockStatus("2")),
|
||||
|
@ -266,7 +258,7 @@ class NetworkTimelineRemoteMediatorTest {
|
|||
),
|
||||
)
|
||||
},
|
||||
accountManager = accountManager,
|
||||
activeAccount = activeAccount,
|
||||
factory = pagingSourceFactory,
|
||||
pageCache = pages,
|
||||
timeline = Timeline.Home,
|
||||
|
|
|
@ -19,7 +19,6 @@ package app.pachli.components.timeline
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
||||
|
@ -27,28 +26,33 @@ import app.pachli.core.data.repository.AccountManager
|
|||
import app.pachli.core.data.repository.ContentFiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd
|
||||
import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.network.retrofit.NodeInfoApi
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.testing.failure
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.core.testing.success
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.HiltTestApplication_Application
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.github.michaelbull.result.andThen
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
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.mockito.kotlin.reset
|
||||
|
@ -99,19 +103,34 @@ abstract class NetworkTimelineViewModelTestBase {
|
|||
/** Exception to throw when testing errors */
|
||||
protected val httpException = HttpException(emptyError)
|
||||
|
||||
private val account = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setup() = runTest {
|
||||
hilt.inject()
|
||||
|
||||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception())
|
||||
onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account)
|
||||
onBlocking { getInstanceV2(anyOrNull()) } doReturn success(DEFAULT_INSTANCE_V2)
|
||||
onBlocking { getCustomEmojis() } doReturn failure()
|
||||
onBlocking { getContentFilters() } doReturn success(emptyList())
|
||||
onBlocking { listAnnouncements(anyOrNull()) } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
reset(nodeInfoApi)
|
||||
nodeInfoApi.stub {
|
||||
onBlocking { nodeInfoJrd() } doReturn NetworkResult.success(
|
||||
onBlocking { nodeInfoJrd() } doReturn success(
|
||||
UnvalidatedJrd(
|
||||
listOf(
|
||||
UnvalidatedJrd.Link(
|
||||
|
@ -121,39 +140,28 @@ abstract class NetworkTimelineViewModelTestBase {
|
|||
),
|
||||
),
|
||||
)
|
||||
onBlocking { nodeInfo(any()) } doReturn NetworkResult.success(
|
||||
onBlocking { nodeInfo(any()) } doReturn success(
|
||||
UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")),
|
||||
)
|
||||
}
|
||||
|
||||
accountManager.addAccount(
|
||||
accountManager.verifyAndAddAccount(
|
||||
accessToken = "token",
|
||||
domain = "domain.example",
|
||||
clientId = "id",
|
||||
clientSecret = "secret",
|
||||
oauthScopes = "scopes",
|
||||
newAccount = Account(
|
||||
id = "1",
|
||||
localUsername = "username",
|
||||
username = "username@domain.example",
|
||||
displayName = "Display Name",
|
||||
createdAt = Date.from(Instant.now()),
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = "",
|
||||
header = "",
|
||||
),
|
||||
)
|
||||
.andThen { accountManager.setActiveAccount(it) }
|
||||
.onSuccess { accountManager.refresh(it) }
|
||||
|
||||
timelineCases = mock()
|
||||
|
||||
viewModel = NetworkTimelineViewModel(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Bookmarks)),
|
||||
networkTimelineRepository,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
contentFiltersRepository,
|
||||
accountManager,
|
||||
statusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue