1
0
mirror of https://github.com/tuskyapp/Tusky synced 2024-12-12 17:17:09 +01:00
Tusky-App-Android/doc/ViewModelInterface.md
Nik Clayton 4d401c7878
Convert NotificationsFragment and related code to Kotlin, use the Paging library (#3159)
* Unmodified output from "Convert Java to Kotlin" on NotificationsFragment.java

* Bare minimum changes to get this to compile and run

- Use `lateinit` for `eventhub`, `adapter`, `preferences`, and `scrolllistener`
- Removed override for accountManager, it can be used from the superclass
- Add `?.` where non-nullity could not (yet) be guaranteed
- Remove `?` from type lists where non-nullity is guaranteed
- Explicitly convert lists to mutable where necessary
- Delete unused function `findReplyPosition`

* Remove all unnecessary non-null (!!) assertions

The previous change meant some values are no longer nullable. Remove the
non-null assertions.

* Lint ListStatusAccessibilityDelegate call

- Remove redundant constructor
- Move block outside of `()`

* Use `let` when handling compose button visibility on scroll

* Replace a `requireNonNull` with `!!`

* Remove redundant return values

* Remove or rename unused lambda parameters

* Remove unnecessary type parameters

* Remove unnecessary null checks

* Replace cascading-if statement with `when`

* Simplify calculation of `topId`

* Use more appropriate list properties and methods

- Access the last value with `.last()`
- Access the last index with `.lastIndex`
- Replace logical-chain with `asRightOrNull` and `?.`
- `.isNotEmpty()`, not `!...isEmpty()`

* Inline unnecessary variable

* Use PrefKeys constants instead of bare strings

* Use `requireContext()` instead of `context!!`

* Replace deprecated `onActivityCreated()` with `onViewCreated()`

* Remove unnecessary variable setting

* Replace `size == 0` check with `isEmpty()`

* Format with ktlint, no functionality changes

* Convert NotifcationsAdapter to Kotlin

Does not compile, this is the unchanged output of the "Convert to Kotlin"
function

* Minimum changes to get NotificationsAdapter to compile

* Remove unnecessary visibility modifiers

* Use `isNotEmpty()`

* Remove unused lambda parameters

* Convert cascading-if to `when`

* Simplifiy assignment op

* Use explicit argument names with `copy()`

* Use `.firstOrNull()` instead of `if`

* Mark as lateinit to avoid unnecessary null checks

* Format with ktlint, whitespace changes only

* Bare minimum necessary to demonstrate paging in notifications

Create `NotificationsPagingSource`. This uses a new `notifications2()` API
call, which will exist until all the code has been adapted. Instead of
using placeholders,

Create `NotificationsPagingAdapter` (will replace `NotificationsAdapater`)
to consume this data.

Expose the paging source view a new `NotificationsViewModel` `flow`, and
submit new pages to the adapter as they are available in
`NotificationsFragment`.

Comment out any other code in `NotificationsFragment` that deals with
loading data from the network. This will be updated as necessary, either
here, or in the view model.

Lots of functionality is missing, including:

- Different views for different notification types
- Starting at the remembered notification position
- Interacting with notifications
- Adjusting the UI state to match the loading state

These will be added incrementally.

* Migrate StatusNotificationViewHolder impl. to NotificationsPagingAdapter

With this change `NotificationsPagingAdapter` shows notifications about a
status correctly.

- Introduce a `ViewHolder` abstract class that all Notification view holders
  derive from. Modify the fallback view holder to use this.

- Implement `StatusNotificationViewHolder`. Much of the code is from the
  existing implementation in the `NotificationAdapater`.

- The original code split the code that binds values to views between the
  adapter's `bindViewHolder` method and the view holder's methods.

  In this code, all of the binding code is in the view holder, in a `bind`
  method. This is called by the adapter's `bindViewHolder` method. This keeps
  all the binding logic in the view holder, where it belongs.

- The new `StatusNotificationViewHolder` uses view binding to access its views
  instead of `findViewById`.

- Logically, information about whether to show sensitive media, or open
  content warnings should be part of the `StatusDisplayOptions`. So add those
  as fields, and populate them appropriately.

  This affects code outside notification handling, which will be adjusted
  later.

* Note some TODOs to complete before the PR is finished

* Extract StatusNotificationViewHolder to a new file

* Add TODO for NotificationViewData.Concrete

* Convert the adapter to take NotificationViewData.Concrete

* Add a view holder for regular status notifications

* Migrate Follow and FollowRequest notifications

* Migrate report notifications

* Convert onViewThread to use the adapter data

* Convert onViewMedia to use the adapter data

* Convert onMore to use the adapter data

* Convert onReply to use the adapter data

* Convert NotificationViewData to Kotlin

* Re-implement the reblog functionality

- Move reblogging in to the view model
- Update the UI via the adapter's `snapshot()` and `notifyItemChanged()`
  methods

* Re-implement the favourite functionality

Same approach as reblog

* Re-implement the bookmark functionality

Same approach as reblog

* Add TODO re StatusActionListener interface

* Add TODO re event handling

* Re-implementing the voting functionality

* Re-implement viewing hidden content

- Hidden media
- Content behind a content warning

* Add a TODO re pinning

* Re-implement "Show more" / "Show less"

* Delete unused updateStatus() function

* Comment out the scroll listener for the moment

* Re-implement applying filters to notifications

Introduce `NotificationsRepository`, to provide access to the notifications
stream.

When changing the filters the flow is as follows:

- User clicks "Apply" in the fragment.

- Fragment calls `viewModel.accept()` with a `UiAction.ApplyFilter` (new
  class).

- View model maintains a private flow of incoming UI actions. The new action
  is emitted to that flow.

- In view model, `notificationFilter` waits for `.ApplyFilter` actions, and
  ensures the filter is saved, then emits it.

- In view model, `pagingDataFlow` waits for new items from
  `notificationsFilter` and fetches the notifications from the repository in
  response. The repository provides `Notification`, so the model maps them to
  `NotificationViewData.Concrete` for display by the adapter.

- In view model the UI state also waits for new items from
  `notificationsFilter` and emits a new `UiState` every time the filter is
  changed.

When opening the fragment for the first time:

- All of the above machinery, but `notificationFilter` also fetches the filter
  from the active account and emits that first. This triggers the first fetch
  and the first update of `uiState`.

Also:

- Add TODOs for functionality that is not implemented yet

- Delete a lot of dead code from NotificationsFragment

* Include important preference values in `uiState`

Listen to the flow of eventHub events, filtered to preference changes that
are relevant to the notification view.

When preferences change (or when the view model starts), fetch the current
values, and include them in `uiState`.

Remove preference handling from `NotificationsFragment`, and just use
the values from `uiState`.

Adjust how the `useAbsoluteTime` preference is handled. The previous code
loaded new content (via a diffutil) in to the adapter, which would trigger
a re-binding of the timestamp.

As the adapter content is immutable, the new code simply triggers a
re-binding of the views that are currently visible on screen.

* Update UI in response to different load states

Notifications can be loaded at the top and bottom of the timeline. Add a
new layout to show the progress of these loads, and any errors that can
occur.

Catch network errors in `NotificationsPagingSource` and convert to
`LoadState.Error`.

Add a header/footer to the notifications list to show the load state.

Collect the load state from the adapter, use this to drive the visibility
of different views.

* Save and restore the last read notification ID

Use this when fetching notifications, to centre the list around the
notification that was last read.

* Call notifyItemRangeChanged with the correct parameters

* Don't try and save list position if there are no items in the list

* Show/hide the "Nothing to see" view appropriately

* Update comments

* Handle the case where the notification key no longer exists

* Re-implement support for showMediaPreview and other settings

* Re-implement "hide FAB when scrolling" preference

* Delete dead code

* Delete Notifications Adapater and Placeholder types

* Remove NotificationViewData.Concrete subclass

Now there's no Placeholder, everything is a NotificationViewData.

* Improve how notification pages are loaded if the first notification is missing or filtered

* Re-implement clear notifications, show errors

* s/default/from/

* Add missing headers

* Don't process bookmarking via EventHub

- Initiating a bookmark is triggered by the fragment sending a
  StatusUiAction.Bookmark
- View model receives this, makes API call, waits for response, emits either
  a success or failure state
- Fragment collects success/failure states, updates the UI accordingly

* Don't process favourites via EventHub

* Don't process reblog via EventHub

* Don't process poll votes with EventHub

This removes EventHub from the fragment

* Respond to follow requests via the view model

* Docs and cleanup

* Typo and editing pass

* Minor edits for clarity

* Remove newline in diagram

* Reorder sequence diagram

* s/authorize/accept/

* s/pagingDataFlow/pagingData/

* Add brief KDoc

* Try and fetch a full first page of notifications

* Call the API method `notifications` again

* Log UI errors at the point of handling

* Remove unused variable

* Replace String.format() with interpolation

* Convert NotificationViewData to data class

* Rename copy() to make(), to avoid confusion with default copy() method

* Lint

* Update app/src/main/res/layout/simple_list_item_1.xml

* Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt

* Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt

* Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt

* Update app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt

* Initial NotificationsViewModel tests

* Add missing import

* More tests, some cleanup

* Comments, re-order some code

* Set StateRestorationPolicy.PREVENT_WHEN_EMPTY

* Mark clearNotifications() as "suspend"

* Catch exceptions from clearNotifications and emit

* Update TODOs with explanations

* Ensure initial fetch uses a null ID

* Stop/start collecting pagingData based on the lifecycle

* Don't hide the list while refreshing

* Refresh notifications on mutes and blocks

* Update tests now clearNotifications is a suspend fun

* Add "Refresh" menu to NotificationsFragment

* Use account.name over account.displayName

* Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>

* Mark layoutmanager as lateinit

* Mark layoutmanager as lateinit

* Refactor generating UI text

* Add Copyright header

* Correctly apply notification filters

* Show follow request header in notifications

* Wait for follow request actions to complete, so the reqeuest is sent

* Remove duplicate copyright header

* Revert copyright change in unmodified file

* Null check response body

* Move NotificationsFragment to component.notifications

* Use viewlifecycleowner.lifecyclescope

* Show notification filter as a dialog rather than a popup window

The popup window:

- Is inconsistent UI
- Requires a custom layout
- Didn't play nicely with viewbinding

* Refresh adapter on block/mute

* Scroll up slightly when new content is loaded

* Restore progressbar

* Lint

* Update app/src/main/res/layout/simple_list_item_1.xml

---------

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
2023-03-10 20:12:33 +01:00

18 KiB

View model interface

Synopsis

This document explains how data flows between the view model and the UI it is serving (either an Activity or Fragment).

Note: At the time of writing this is correct for NotificationsViewModel and NotificationsFragment. Other components will be updated over time.

After reading this document you should understand:

  • How user actions in the UI are communicated to the view model
  • How changes in the view model are communicated to the UI

Before reading this document you should:

Action and UiState flows

The basics

Every action between the user and application can be reduced to the following:

sequenceDiagram
    actor user as User
    participant ui as Fragment
    participant vm as View Model
    user->>+ui: Performs UI action
    ui->>+vm: Sends action
    vm->>-ui: Sends new UI state
    ui->>ui: Updates visible UI
    ui-->>-user: Observes changes

In this model, actions always flow from left to right. The user tells the fragment to do something, then te fragment tells the view model to do something.

The view model does not tell the fragment to do something.

State always flows from right to left. The view model tells the fragment "Here's the new state, it up to you how to display it."

Not shown on this diagram, but implicit, is these actions are asynchronous, and the view model may be making one or more requests to other components to gather the data to use for the new UI state.

Rather than modelling this transfer of data as function calls, and by passing callback functions from place to place they can be modelled as Kotlin flows between the Fragment and View Model.

For example:

  1. The View Model creates two flows and exposes them to the Fragment.
// In the View Model
data class UiAction(val action: String) { ... }

data class UiState(...) { ... }

val actionFlow = MutableSharedFlow<UiAction>()
val uiStateFlow = StateFlow<UiState>()

init {
    // ...

    viewModelScope.launch {
        actionFlow
            .collect {
                // Do work
                // ... work is complete

                // Update UI state
                uiStateFlow.emit(uiStatFlow.value.update { ... })
            }
    }

    // ...
}
  1. The fragment collects from uiStateFlow, and updates the visible UI, and emits new UiAction objects in to actionFlow in response to the user interacting with the UI.
// In the Fragment
fun onViewCreated(...) {
    // ...

    binding.button.setOnClickListener {
        // Won't work, see section "Accepting user actions from the UI" for why
        viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
    }

    lifecycleScope.launch {
        viewModel.uiStateFlow.collectLatest { uiState ->
            updateUiWithState(uiState)
        }
    }

    // ...
}

This is a good start, but it can be me significantly improved.

Model actions with sealed classes

The prototypical example in the previous section suggested the UiAction could be modelled as

data class UiAction(val action: String) { ... }

This is not great.

  • It's stringly-typed, with opportunity for run time errors
  • Trying to store all possible UI actions in a single type will lead to a plethora of different properties, only some of which are valid for a given action.

These problems can be solved by making UiAction a sealed class, and defining subclasses, one per action.

In the case of NotificationsFragment the actions the user can take in the UI are:

  • Apply a filter to the set of notifications
  • Clear the current set of notifications
  • Save the ID of the currently visible notification in the list

NOTE: The user can also interact with items in the list of the notifications.

That is handled a little differently because of how code outside NotificationsFragment is currently written. It will be adjusted at a later time.

That becomes:

// In the View Model
sealed class UiAction {
    data class ApplyFilter(val filter: Set<Filter>) : UiAction()
    object ClearNotifications : UiAction()
    data class SaveVisibleId(val visibleId: String) : UiAction()
}

This has multiple benefits:

  • The actions the view model can act on are defined in a single place
  • Each action clearly describes the information it carries with it
  • Each action is strongly typed; it is impossible to create an action of the wrong type
  • As a sealed class, using the when statement to process actions gives us compile-time guarantees all actions are handled

In addition, the view model can spawn multiple coroutines to process the different actions, by filtering out actions dependent on their type, and using other convenience methods on flows. For example:

// In the View Model
val actionFlow = MutableSharedFlow<UiAction>() // As before

init {
    // ...
    
    handleApplyFilter()
    handleClearNotifications()
    handleSaveVisibleId()
    
    // ...
}

fun handleApplyFilter() = viewModelScope.launch {
    actionFlow
        .filterIsInstance<UiAction.ApplyFilter>()
        .distinctUntilChanged()
        .collect { action ->
            // Apply the filter, update state
        }
}

fun handleClearNotifications() = viewModelScope.launch {
    actionFlow
        .filterIsInstance<UiAction.ClearNotifications>()
        .distinctUntilChanged()
        .collect { action -> 
            // Clear notifications, update state
        }
}

fun handleSaveVisibleId() = viewModelScope.launch {
    actionFlow
        .filterIsInstance<UiAction.SaveVisibleId>()
        .distinctUntilChanged()
        .collect { action ->
            // Save the ID, no need to update state
        }
}

Each of those runs in separate coroutines and ignores duplicate events.

Accepting user actions from the UI

Example code earlier had this snippet, which does not work.

// In the Fragment
binding.button.setOnClickListener {
    // Won't work, see section "Accepting user actions from the UI" for why
    viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
}

This fails because emit() is a suspend fun, so it must be called from a coroutine scope.

To fix this, provide a function or property in the view model that accepts UiAction and emits them in actionFlow under the view model's scope.

// In the View Model
val accept: (UiAction) -> Unit = { action ->
    viewModelScope.launch { actionFlow.emit(action)}
}

When the Fragment wants to send a UiAction to the view model it:

// In the Fragment
binding.button.setOnClickListener {
    viewModel.accept(UiAction.ClearNotifications)
}

Model the difference between fallible and infallible actions

An infallible action either cannot fail, or, can fail but there are no user-visible changes to the UI.

Conversely, a fallible action can fail and the user should be notified.

I've found it helpful to distinguish between the two at the type level, as it simplifies error handling in the Fragment.

So the actions in NotificationFragment are modelled as:

// In the View Model
sealed class UiAction

sealed class FallibleUiAction : UiAction() {
    // Actions that can fail are modelled here
    // ...
}

sealed class InfallibleUiAction : UiAction() {
    // Actions that cannot fail are modelled here
    // ...
}

Additional UiAction subclasses

It can be useful to have a deeper UiAction class hierarchy, as filtering flows by the class of item in the flow is straightforward.

NotificationsViewModel splits the fallible actions the user can take as operating on three different parts of the UI:

  • Everything not the list of notifications
  • Notifications in the list of notifications
  • Statuses in the list of notifications

Those last two are modelled as:

// In the View Model
sealed class NotificationAction : FallibleUiAction() {
    // subclasses here
}

sealed class StatusAction(
    open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
    // subclasses here
}

Separate handling for actions on notifications and statuses is then achieved with code like:

viewModelScope.launch {
    uiAction.filterIsInstance<NotificationAction>()
        .collect { action ->
            // Process notification actions here
        }
}

viewModelScope.launch {
    uiAction.filterIsInstance<StatusAction>()
        .collect { action ->
            // Process status actions where
        }
}

At the time of writing the UI action hierarchy for NotificationsViewModel is:

classDiagram
  direction LR
  UiAction <|-- InfallibleUiAction
  InfallibleUiAction <|-- SaveVisibleId
  InfallibleUiAction <|-- ApplyFilter
  UiAction <|-- FallibleUiAction
  FallibleUiAction <|-- ClearNotifications
  FallibleUiAction <|-- NotificationAction
  NotificationAction <|-- AcceptFollowRequest
  NotificationAction <|-- RejectFollowRequest
  FallibleUiAction <|-- StatusAction
  StatusAction <|-- Bookmark
  StatusAction <|-- Favourite
  StatusAction <|-- Reblog
  StatusAction <|-- VoteInPoll
  

Multiple output flows

So far the UI has been modelled as a single output flow of a single UiState type.

For simple UIs that can be sufficient. As the UI gets more complex it can be helpful to separate these in to different flows.

In some cases the Android framework requires you to do this. For example, the flow of PagingData in to the adapter is provided and managed by the PagingData class. You should not attempt to reassign it or update it during normal operation.

Similarly, RecyclerView.Adapter provides its own loadStateFlow, which communicates information about the loading state of data in to the adapter.

For NotificationsViewModel I have found it helpful to provide flows to separate the following types

  • PagingData in to the adapter
  • UiState, representing UI state outside the main RecyclerView
  • StatusDisplayOptions, representing the user's preferences for how all statuses should be displayed
  • UiSuccess, representing transient notifications about a fallible action succeeding
  • UiError, representing transient notifications about a fallible action failing

There are separated this way to roughly match how the Fragment will want to process them.

  • PagingData is handed to the adapter and not modified by the Fragment
  • UiState is generally updated no matter what has changed.
  • StatusDisplayOptions is handled by rebinding all visible items in the list, without disturbing the rest of the UI
  • UiSuccess show a brief snackbar without disturbing the rest of the UI
  • UiError show a fixed snackbar with a "Retry" option

They also have different statefulness requirements, which makes separating them in to different flows a sensible approach.

PagingData, UiState, and StatusDisplayOptions are stateful -- if the Fragment disconnects from the flow and then reconnects (e.g., because of a configuration change) the Fragment should receive the most recent state of each of these.

UiSuccess and UiError are not stateful. The success and error messages are transient; if one has been shown, and there is a subsequent configuration change the user should not see the success or error message again.

Modelling success and failure for fallible actions

A fallible action should have models capturing success and failure information, and be communicated to the UI.

Note: Infallible actions, by definition, neither succeed or fail, so there is no need to model those states for them.

Suppose the user has clicked on the "bookmark" button on a status, sending a UiAction.FallibleAction.StatusAction.Bookmark(...) to the view model.

The view model processes the action, and is successful.

To signal this back to the UI it emits a UiSuccess subclass for the action's type in to the uiSuccess flow, and includes the original action request.

You can read this as the action in the UiAction is a message from the Fragment saying "Here is the action I want to be performed" and the action in UiSuccess is the View Model saying "Here is the action that was carried out."

Unsurprisingly, this is modelled with a UiSuccess class, and per-action subclasses.

Failures are modelled similarly, with a UiError class. However, details about the error are included, as well as the original action.

So each fallible action has three associated classes; one for the action, one to represent the action succeeding, and one to represent the action failing.

For the single "bookmark a status" action the code for its three classes looks like this:

// In the View Model
sealed class StatusAction(
    open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
    data class Bookmark(
        val state: Boolean,
        override val statusViewData: StatusViewData.Concrete
    ) : StatusAction(statusViewData)
  
    // ... other actions here
}

sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () {
    data class Bookmark(override val action: StatusAction.Bookmark) :
        StatusActionSuccess(action)
  
    // ... other action successes here
  
    companion object {
        fun from (action: StatusAction) = when (action) {
            is StatusAction.Bookmark -> Bookmark(action)
            // ... other actions here
        }
    }
}

sealed class UiError(
    open val exception: Exception,
    @StringRes val message: Int,
    open val action: UiAction? = null
) {
    data class Bookmark(
      override val exception: Exception,
      override val action: StatusAction.Bookmark
    ) : UiError(exception, R.string.ui_error_bookmark, action)
  
    // ... other action errors here

    companion object {
        fun make(exception: Exception, action: FallibleUiAction) = when (action) {
            is StatusAction.Bookmark -> Bookmark(exception, action)
            // other actions here
      }
    }
}

Note: I haven't found it necessary to create subclasses for UiError, as all fallible errors (so far) are handled identically. This may change in the future.

Receiving status actions in the view model (from the uiAction flow) is then:

// In the View Model
viewModelScope.launch {
    uiAction.filterIsInstance<StatusAction>()
      .collect { action ->
          try {
              when (action) {
                  is StatusAction.Bookmark -> {
                     // Process the request
                  }
                  // Other action types handled here
              }
              uiSuccess.emit(StatusActionSuccess.from(action))
          } catch (e: Exception) {
              uiError.emit(UiError.make(e, action))
          }
      }
}

Basic success handling in the fragment would be:

// In the Fragment
lifecycleScope.launch {
    // Show a generic message when an action succeeds
    this.launch {
        viewModel.uiSuccess.collect {
            Snackbar.make(binding.root, "Success!", LENGTH_SHORT).show()
        }
    }
}

In practice it is more complicated, with different actions depending on the type of success.

Basic error handling in the fragment would be:

lifecycleScope.launch {
    // Show a specific error when an action fails
    this.launch {
        viewModel.uiError.collect { error ->
            SnackBar.make(
                binding.root,
                getString(error.message),
                LENGTH_LONG
            ).show()
        }
    }
}

Supporting "retry" semantics

This approach has an extremely helpful benefit. By including the original action in the UiError response, implementing a "retry" function is as simple as re-sending the original action (included in the error) back to the view model.

lifecycleScope.launch {
    // Show a specific error when an action fails. Provide a "Retry" option
    // on the snackbar, and re-send the original action to retry.
    this.launch {
        viewModel.uiError.collect { error ->
            val snackbar = SnackBar.make(
                binding.root,
                getString(error.message),
                LENGTH_LONG
            )
            error.action?.let { action -> 
                snackbar.setAction("Retry") { viewModel.accept(action) }
            }
            snackbar.show()
        }
    }
}

Updated sequence diagram

sequenceDiagram
    actor user as User
    participant ui as Fragment
    participant vm as View Model
    user->>ui: Performs UI action
    activate ui
    ui->>+vm: viewModel.accept(UiAction.*())
    deactivate ui
    vm->>vm: Perform action
    alt Update UI state?
      vm->>vm: emit(UiState(...))
      vm-->>ui: UiState(...)
      activate ui
      ui->>ui: collect UiState, update UI
      deactivate ui
      
    else Update StatusDisplayOptions?
      vm->>vm: emit(StatusDisplayOptions(...))
      vm-->>ui: StatusDisplayOption(...)
      activate ui
      ui->>ui: collect StatusDisplayOptions, rebind list items
      deactivate ui

    else Successful fallible action
      vm->>vm: emit(UiSuccess(...))
      vm-->>ui: UiSuccess(...)
      activate ui
      ui->>ui: collect UiSuccess, show snackbar
      deactivate ui

    else Failed fallible action
      vm->>vm: emit(UiError(...))
      vm-->>ui: UiError(...)
      activate ui
      deactivate vm
      ui->>ui: collect UiError, show snackbar with retry
      deactivate ui
      user->>ui: Presses "Retry"
      activate ui
      ui->>vm: viewModel.accept(error.action)
      deactivate ui
      activate vm
      vm->>vm: Perform action, emit response...
      deactivate vm 
    end
    note over ui,vm: Type of UI change depends on type of object emitted<br>UiState, StatusDisplayOptions, UiSuccess, UiError
      
    ui-->>user: Observes changes