Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ private fun reportEntryProvider(
private fun FlowReasonSelection(subject: ReportSubject) {
val viewModel = flowSharedViewModel<ReportViewModel>()
val flowNavigator = rememberFlowNavigator<ReportStep, Parcelable>()
val progress by viewModel.state.collectAsStateWithLifecycle()
val progress by viewModel.stateFlow.collectAsStateWithLifecycle()

ExitOnceConfirmed(viewModel, flowNavigator)

Expand All @@ -110,7 +110,7 @@ private fun FlowReasonSelection(subject: ReportSubject) {
} else {
// Every other reason is the whole report, so the pick is the submit. Asking for
// more after someone has already said what is wrong would be asking twice.
viewModel.submit(subject, reason, details = null)
viewModel.dispatchEvent(ReportViewModel.Event.Submit(subject, reason, details = null))
}
},
// `back` on its own: at the flow's root it exits the flow itself, so pairing it with an
Expand All @@ -127,14 +127,14 @@ private fun FlowReportDetails(subject: ReportSubject, reason: ReportReason) {
// typed — leaving by the back arrow is usually a second thought about the reason, not about
// the words.
val details = rememberTextFieldState()
val progress by viewModel.state.collectAsStateWithLifecycle()
val progress by viewModel.stateFlow.collectAsStateWithLifecycle()

ExitOnceConfirmed(viewModel, flowNavigator)

ReportDetailsContent(
state = details,
progress = progress,
onSubmit = { typed -> viewModel.submit(subject, reason, typed) },
onSubmit = { typed -> viewModel.dispatchEvent(ReportViewModel.Event.Submit(subject, reason, typed)) },
// Not the flow's root, so this steps back to the reasons rather than leaving.
onNavigateUp = { flowNavigator.back() },
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package com.flipcash.app.messenger.internal

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.app.core.chat.ReportSubject
import com.flipcash.features.messenger.R
import com.flipcash.reporting.ReportDescription
import com.flipcash.reporting.ReportReason
import com.flipcash.services.controllers.ReportingController
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.models.ReportTarget
import com.getcode.manager.BottomBarManager
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.trace
import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
import com.getcode.view.MinimumLoadingDuration
import com.getcode.view.SuccessHoldDuration
Expand All @@ -19,9 +20,9 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
Expand All @@ -43,18 +44,26 @@ import javax.inject.Inject
internal class ReportViewModel @Inject constructor(
private val reporting: ReportingController,
private val resources: ResourceHelper,
) : ViewModel() {
dispatchers: DispatcherProvider,
) : BaseViewModel<LoadingSuccessState, ReportViewModel.Event>(
// What the submit button is showing: the send in flight, then that it landed.
//
// It stays on [LoadingSuccessState.success] rather than returning to idle, because the flow is
// still on screen behind the confirmation and a button back to "Submit Report" would be
// offering to file the same report again.
initialState = LoadingSuccessState(),
updateStateForEvent = updateStateForEvent,
defaultDispatcher = dispatchers.Default,
) {

private val _state = MutableStateFlow(LoadingSuccessState())
sealed interface Event {
/** [details] is the reporter's own words, and only [ReportReason.Other] collects them. */
data class Submit(val subject: ReportSubject, val reason: ReportReason, val details: String?) : Event

/**
* What the submit button is showing: the send in flight, then that it landed.
*
* It stays on [LoadingSuccessState.success] rather than returning to idle, because the flow is
* still on screen behind the confirmation and a button back to "Submit Report" would be
* offering to file the same report again.
*/
val state: StateFlow<LoadingSuccessState> = _state.asStateFlow()
data object Sending : Event
data object Sent : Event
data object SendFailed : Event
}

// Buffered rather than a shared flow: this fires once, from a bottom bar callback that can
// outlive the composition collecting it, and losing it would strand the flow open.
Expand All @@ -68,11 +77,17 @@ internal class ReportViewModel @Inject constructor(
*/
val confirmed: Flow<Unit> = _confirmed.receiveAsFlow()

/** [details] is the reporter's own words, and only [ReportReason.Other] collects them. */
fun submit(subject: ReportSubject, reason: ReportReason, details: String?) {
init {
eventFlow
.filterIsInstance<Event.Submit>()
.onEach { submit(it.subject, it.reason, it.details) }
.launchIn(viewModelScope)
}

private fun submit(subject: ReportSubject, reason: ReportReason, details: String?) {
// Covers a second press both while the first is in flight and after it has succeeded —
// either would file the same report twice and answer with two confirmations.
if (!_state.value.isIdle) return
if (!stateFlow.value.isIdle) return

val target = when (subject) {
is ReportSubject.User -> ReportTarget.User(subject.userId)
Expand All @@ -83,8 +98,9 @@ internal class ReportViewModel @Inject constructor(
)
}

// Set before the launch, so a second Submit queued behind this one sees it.
dispatchEvent(Event.Sending)
viewModelScope.launch {
_state.value = LoadingSuccessState(loading = true)

// A report is a single unary call and usually answers inside a frame, so the button
// crossfaded from its label straight to a checkmark and the send read as never having
Expand All @@ -98,7 +114,7 @@ internal class ReportViewModel @Inject constructor(
sent.await()
.onSuccess {
floor.join()
_state.value = LoadingSuccessState(success = true)
dispatchEvent(Event.Sent)
// The checkmark is drawn on the frame after this is set, so the confirmation
// waits out the same hold every other confirm button in the app does. Raising
// it immediately would put a scrim over a checkmark nobody had seen yet.
Expand All @@ -122,12 +138,23 @@ internal class ReportViewModel @Inject constructor(
// Back to idle, not to an error state: the flow stays where it is with the
// reason still picked, so the retry is one press on a button that has to be
// pressable to take it.
_state.value = LoadingSuccessState()
dispatchEvent(Event.SendFailed)
BottomBarManager.showError(
title = resources.getString(R.string.error_title_failedToReport),
message = resources.getString(R.string.error_description_failedToReport),
)
}
}
}

companion object {
val updateStateForEvent: (Event) -> ((LoadingSuccessState) -> LoadingSuccessState) = { event ->
when (event) {
is Event.Submit -> { state -> state }
Event.Sending -> { _ -> LoadingSuccessState(loading = true) }
Event.Sent -> { _ -> LoadingSuccessState(success = true) }
Event.SendFailed -> { _ -> LoadingSuccessState() }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.flipcash.app.messenger.internal

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.app.core.toast.SystemToastController
import com.flipcash.features.messenger.R
Expand All @@ -13,11 +12,11 @@ import com.getcode.manager.BottomBarManager
import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.trace
import com.getcode.view.BaseViewModel
import com.flipcash.libs.coroutines.DispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -37,39 +36,92 @@ internal class RestrictedMembersViewModel @Inject constructor(
private val resources: ResourceHelper,
private val toastController: SystemToastController,
private val restrictionWrites: GroupRestrictionWrites,
) : ViewModel() {
dispatchers: DispatcherProvider,
) : BaseViewModel<RestrictedMembersViewModel.State, RestrictedMembersViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
defaultDispatcher = dispatchers.Default,
) {

data class State(
val loading: Boolean = true,
val failed: Boolean = false,
val users: List<RestrictedUser> = emptyList(),
/** Users whose Unsilence or Unban is in flight. */
val pending: Set<ID> = emptySet(),
/**
* The chat the last read was for. A repeat [Event.Load] for it is dropped, since the flow's
* back stack reopens this screen; a failed read clears it so the next open retries.
*/
val loadedFor: ChatId? = null,
) {
fun rows(kind: RestrictedKind): List<RestrictedUser> = when (kind) {
RestrictedKind.Silenced -> users.filter { it.ban == null && it.silence.isActiveAt() }
RestrictedKind.Removed -> users.filter { it.ban != null }
}
}

private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
sealed interface Event {
/** Read every page for [chatId], once per view model; the flow's back stack reopens this one. */
data class Load(val chatId: ChatId) : Event

private var loadedFor: ChatId? = null
/** Lift [userId]'s silence. [name] goes in the success toast. */
data class Unsilence(val chatId: ChatId, val userId: ID, val name: String) : Event

/** Reads every page for [chatId] once per view model; the flow's back stack reopens this one. */
fun load(chatId: ChatId) {
if (loadedFor == chatId) return
loadedFor = chatId
_state.update { State() }
/** Lift [userId]'s ban. */
data class Unban(val chatId: ChatId, val userId: ID) : Event

/** A new read for [chatId] started; drops whatever the last one showed. */
data class LoadStarted(val chatId: ChatId) : Event
data class Loaded(val users: List<RestrictedUser>) : Event
data object LoadFailed : Event

data class RestrictionPending(val userId: ID) : Event
data class Unsilenced(val userId: ID) : Event
data class Unbanned(val userId: ID) : Event
data class RestrictionFailed(val userId: ID) : Event
}

init {
// One collector, so a second tap on a row sees the first one's pending entry.
eventFlow
.onEach { event ->
when (event) {
is Event.Load -> load(event.chatId)
is Event.Unsilence -> run(
userId = event.userId,
call = { chatController.unsilenceUser(event.chatId, event.userId) },
done = Event.Unsilenced(event.userId),
onSuccess = {
restrictionWrites.lifted(event.chatId, event.userId)
toastController.showToast(R.string.toast_unsilenced, event.name, replacePrevious = true)
},
titleRes = R.string.error_title_failedToUnsilence,
messageRes = R.string.error_description_failedToUnsilence,
)
is Event.Unban -> run(
userId = event.userId,
call = { chatController.unbanUser(event.chatId, event.userId) },
done = Event.Unbanned(event.userId),
titleRes = R.string.error_title_failedToUnban,
messageRes = R.string.error_description_failedToUnban,
)
else -> Unit
}
}
.launchIn(viewModelScope)
}

private fun load(chatId: ChatId) {
if (stateFlow.value.loadedFor == chatId) return
dispatchEvent(Event.LoadStarted(chatId))
viewModelScope.launch {
val all = mutableListOf<RestrictedUser>()
var options = QueryOptions()
while (true) {
val page = chatController.getRestrictedUsers(chatId, options).getOrElse {
trace("failed to load restricted users - ${it.localizedMessage}")
loadedFor = null
_state.update { s -> s.copy(loading = false, failed = true) }
dispatchEvent(Event.LoadFailed)
BottomBarManager.showError(
title = resources.getString(R.string.error_title_failedToLoadRestricted),
message = resources.getString(R.string.error_description_failedToLoadRestricted),
Expand All @@ -80,63 +132,64 @@ internal class RestrictedMembersViewModel @Inject constructor(
if (!page.hasMore || page.pagingToken == null) break
options = options.copy(token = page.pagingToken)
}
_state.update { it.copy(loading = false, users = all) }
dispatchEvent(Event.Loaded(all))
}
}

fun unsilence(chatId: ChatId, userId: ID, name: String) = run(
userId = userId,
call = { chatController.unsilenceUser(chatId, userId) },
// A removed member who is also silenced stays in Removed, now without the silence.
apply = { u -> if (u.ban != null) u.copy(silence = null) else null },
onSuccess = {
restrictionWrites.lifted(chatId, userId)
toastController.showToast(R.string.toast_unsilenced, name, replacePrevious = true)
},
titleRes = R.string.error_title_failedToUnsilence,
messageRes = R.string.error_description_failedToUnsilence,
)

fun unban(chatId: ChatId, userId: ID) = run(
userId = userId,
call = { chatController.unbanUser(chatId, userId) },
// Unbanning leaves any silence in place, so they move back to Silenced if they had one.
apply = { u -> u.copy(ban = null).takeIf { it.silence.isActiveAt() } },
titleRes = R.string.error_title_failedToUnban,
messageRes = R.string.error_description_failedToUnban,
)

private fun run(
userId: ID,
call: suspend () -> Result<Unit>,
apply: (RestrictedUser) -> RestrictedUser?,
done: Event,
onSuccess: () -> Unit = {},
titleRes: Int,
messageRes: Int,
) {
if (userId in _state.value.pending) return
_state.update { it.copy(pending = it.pending + setOf(userId)) }
if (userId in stateFlow.value.pending) return
dispatchEvent(Event.RestrictionPending(userId))
viewModelScope.launch {
call()
.onSuccess {
_state.update { s ->
s.copy(
pending = s.pending - setOf(userId),
users = s.users.mapNotNull { u ->
if (u.userProfile.userId == userId) apply(u) else u
},
)
}
dispatchEvent(done)
onSuccess()
}
.onFailure {
trace("failed to update restriction - ${it.localizedMessage}")
_state.update { s -> s.copy(pending = s.pending - setOf(userId)) }
dispatchEvent(Event.RestrictionFailed(userId))
BottomBarManager.showError(
title = resources.getString(titleRes),
message = resources.getString(messageRes),
)
}
}
}

companion object {
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
is Event.Load,
is Event.Unsilence,
is Event.Unban -> { state -> state }

is Event.LoadStarted -> { _ -> State(loadedFor = event.chatId) }
is Event.Loaded -> { state -> state.copy(loading = false, users = event.users) }
Event.LoadFailed -> { state -> state.copy(loading = false, failed = true, loadedFor = null) }

is Event.RestrictionPending -> { state -> state.copy(pending = state.pending + setOf(event.userId)) }
is Event.RestrictionFailed -> { state -> state.copy(pending = state.pending - setOf(event.userId)) }
// A removed member who is also silenced stays in Removed, now without the silence.
is Event.Unsilenced -> { state ->
state.lifted(event.userId) { u -> if (u.ban != null) u.copy(silence = null) else null }
}
// Unbanning leaves any silence in place, so they move back to Silenced if they had one.
is Event.Unbanned -> { state ->
state.lifted(event.userId) { u -> u.copy(ban = null).takeIf { it.silence.isActiveAt() } }
}
}
}

private fun State.lifted(userId: ID, apply: (RestrictedUser) -> RestrictedUser?): State = copy(
pending = pending - setOf(userId),
users = users.mapNotNull { u -> if (u.userProfile.userId == userId) apply(u) else u },
)
}
}
Loading
Loading