Skip to content

refactor(messenger): move chat viewer state into its own scope - #1975

Draft
bmc08gt wants to merge 1 commit into
code/cashfrom
refactor/chat-vm-scopes
Draft

bmc08gt wants to merge 1 commit into
code/cashfrom
refactor/chat-vm-scopes

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Moves the viewer's state on a chat (mute, permissions, moderator silence) out of ChatViewModel into its own scope. This is the template for splitting up the rest of the view model; no other scope moves in this PR.

The shape

internal interface ViewerScope                              // what callers reach through `by`

internal data class ViewerSlice(                            // ChatViewModel.State.viewer
    val viewerState: ViewerState? = null,
    val silenceClock: Instant = Clock.System.now(),
) {
    val silence: SilenceState? get() = ...                  // slice-only getters live here
    val canPin: Boolean get() = ...
}

internal sealed interface ViewerEvent : ChatViewModel.Event {
    data class OnViewerStateResolved(val viewerState: ViewerState?) : ViewerEvent
    data object SilenceLapsed : ViewerEvent
    data object SilenceLifted : ViewerEvent
}

internal fun ViewerSlice.reduce(event: ViewerEvent): ViewerSlice = ...

internal class DefaultViewerScope @Inject constructor(
    private val chatCoordinator: ChatCoordinator,
) : ViewerScope {
    fun bind(scope: CoroutineScope, chatId: Flow<ChatId>, slice: Flow<ViewerSlice>, dispatch: (ViewerEvent) -> Unit)
}

// ChatViewModel
class ChatViewModel(..., private val viewer: DefaultViewerScope) : BaseViewModel<State, Event>(...), ViewerScope by viewer
//   init:    viewer.bind(viewModelScope, stateFlow.mapNotNull { it.chatId }, stateFlow.map { it.viewer }, { dispatchEvent(it) })
//   reducer: is ViewerEvent -> { state -> state.copy(viewer = state.viewer.reduce(event)) }

The rules this sets for the scopes that follow:

  • A scope's effects take only what they read from outside the scope, each as its own flow (here, the chat id). They dispatch only the scope's own events, so a scope can't write another scope's slice. ViewerScopeTest binds the scope to a test scope with a fake coordinator and no view model.
  • A getter that reads only one slice lives on that slice. A getter that reads several slices stays on State. canSpeak, isReadOnlySpeaker and cashCardTap all read membership or the speaker gate as well, so they stay. State keeps forwarders for viewerState, silence and canPin, so the 8 screen files that read them are unchanged.
  • Each event set is a sealed interface under ChatViewModel.Event, and the root reducer has one branch per scope.

The constructor takes DefaultViewerScope, not ViewerScope: bind is not on the interface, so by doesn't expose it, and injecting the concrete class needs no @Binds module.

For the headless contract in the cross-platform behaviour-test spec, a scope maps onto one child node: ViewerSlice is the node's state and ViewerEvent is its event set. All three viewer events are internal (the metadata stream, the timer, the lift edge), so this node's actions list would be empty. The public-action marker therefore has to separate events a viewer can send from internal messages.

This scope doesn't exercise two parts of the target: it has no extra MutableStateFlow to fold, and ViewerScope has no members yet, so by forwards nothing. Pins exercises both, which is why it's next in the order below.

The effect chains are moved unchanged; only their source flow is new. The reducer still reads Clock.System.now(), as reduceChatOpen did.

Mechanical updates: ChatFlowScreen and ChatSilenceLiftedToastTest name ViewerEvent.*. ChatSilenceStateTest and three title-bar/screenshot tests build State(viewer = ViewerSlice(...)). The #1924 fixture and ChatPinnedRowsTest pass DefaultViewerScope(chatCoordinator).

Inventory

Line numbers are approximate and refer to ChatViewModel.kt on this branch.

Scope State fields Events Reducer today Effects (lines) Private fields: fold or keep
Viewer (this PR) 2 3 reduceChatOpen ~40 none
Pins pins, pinBarIndex, waitingPin 7 reducePins ~200 (4327-4529) pinVersionHeard: latch, not rendered. Moves into the scope's implementation, not the slice.
Typing 4 7 reduceTyping ~120 (2802-2916) none
E2EE / wrong key wrongKey, isEncrypted 2 reduceChatOpen ~25 none (small enough to merge into identity)
Reactions quickReactionInputs, quickReactionStrip 4 reduceReactions ~110 reactionOverlay: cache, read synchronously by the toggle. Keep.
Unread / jump / return 13 14 reduceJumpReturn, reduceUnread ~230 gatedLandingMessage: raw input to a derived bubble. Keep.
Group gate & rules 7 (groupAccess, speakerBlock*, rule*, profileStanding) 6 reduceChatOpen ~200 none
Membership / lobby / invite 11 26 reduceChatOpen ~420 none
Identity / subject 5 (subject, chatType, isAnonymous, chatNotFound, resolveState) 7 reduceChatOpen ~250 senderProfiles (cache read by 6 scopes) and openedChatId (re-entry guard). Keep both at view-model level.
Payments 5 (sendProgress, groupSend, token, limits, cashSymbol) 21 reduceSend, reduceChatOpen ~520 amountStyleFlow, maxAmountFlow, amountDelegate, sendBack, claimReplyTargets: delegate wiring and latches. Keep.
Composer text / draft / edit / reply / mentions 5 12 reduceMessageAction, reduceSend ~330 draftFlushObserver and the mention picker's refresh latch. Keep.
Composer photos stagedPhotos, uploadStates 9 reduceComposerPhotos ~110 captureFiles (cache), pendingCaptures (latch). Keep.
Link previews composerLink 4 reduceComposerPhotos, reduceSend ~130 _previewsReady: one-way gate on the first draw, exposed as its own StateFlow. Fold into the slice.
Selection & message actions selection, reactionStripOnly, confirmingDelete 11 reduceMessageAction ~120 none
Transcript / paging chatId, separatorConfig, messagePolicy 2 reduceChatOpen ~650 pagingScope, quotedMessages/quotedGeneration: infrastructure and a per-page cache. Keep.

The moderation files from #1933 (GroupModerationEvents.kt, MuteEvents.kt, GroupRestrictionWrites.kt) serve MuteChatViewModel and the moderation view models, not ChatViewModel. The moderation-shaped scope inside ChatViewModel is the group gate.

Cross-scope reads

  • Getters that span scopes stay on root State: canSpeak (gate, viewer), isReadOnlySpeaker and cashCardTap (identity, membership, gate, viewer), showsGatePanel, readsFromOutside, obscuresTranscript, messageLinkLanding, showsJoinRequestsAnchor, groupSendCandidates, composerPhotos/canSendComposer, acceptsMedia.
  • An effect that reads another scope takes that field as its own flow, as bind(chatId = ...) does here. A scope that needs several fields from one other scope gets a narrow read-only interface instead of the whole root state.
  • Most reducer branches that write two scopes only clear selection as a side effect: PinMessage, UnpinMessage, ToggleReaction, ReplyToMessage, EditMessage. If one event reaches both scopes' reducers, each writes only its own slice. quickReactionStrip is stored but always equals reactionStripFor(selection); making it a getter removes the reactions/selection cross-write.
  • Still hard after that:
    • OnGroupResolved: it resets the gate by comparing against the previous subject.
    • SendMessage: its reducer writes returnStack and unreadBoundary, and its effect reads photos, the link preview and the reply.
    • The OnChatOpened handler (2394-2487) touches six scopes.

Conflict map

The branches named when this was planned (feat/chat-send-back, feat/chat-media-save-stack-edit, fix/request-pay-no-balance, feat/standard-cash-bubbles, perf/parallel-async, feat/group-send-per-person) are all merged; their remote branches are squash-merge leftovers. What still edits ChatViewModel.kt:

Source Hunks Scopes touched
#1748 (format render) message mapping, quotes, link cards transcript, link previews
#1750 (format composer) constructor, composer flag composer
feat/chat-polls (local worktree, no PR) constructor, State.poll, 3 events, message actions selection & actions, plus a new polls scope

None of them touch viewer state or silence. Every one adds a constructor parameter, so each later extraction that adds a scope parameter to the constructor will conflict there, trivially.

Proposed order

  1. Pins: folds pinVersionHeard into the scope's implementation, and gives PinsScope real members (pinPreview, pinQuote, pinnedRows) for by to forward.
  2. Typing, then E2EE (merged into identity, or on its own).
  3. Reactions, with quickReactionStrip turned into a getter first.
  4. Unread / jump / return.
  5. Group gate, then membership, then identity. reduceChatOpen is split across these three, so it dissolves here; OnGroupResolved is the hard case.
  6. Payments.
  7. After feat(chat): formatting tools in the chat composer #1750 lands: composer text/draft, then photos.
  8. After feat(chat): render chat text formatting #1748 lands: link previews (fold previewsReady), then transcript.
  9. After polls lands: selection & message actions.

Also noticed, not changed here:

  • ViewerSlice.reduce reads Clock.System.now(). A serializable, replayable slice needs that instant to come in on the event; this changes the event shape and the tests, so it's a separate change.
  • ChatViewModel collects observeMetadata(chatId) five times, and refreshViewerState still runs from openTranscript's stored-chat branch.
  • waitingPin is set on PinCapReached and never cleared in this module; separatorConfig is never written.

The viewer's state on a chat (mute, permissions, moderator silence) and
the clock a timed silence is read against now sit in a ViewerSlice on
ChatViewModel.State, with their three events under ViewerEvent, a pure
reducer over the slice, and the three effect chains (metadata stream,
silence timer, lift edge) in DefaultViewerScope.bind. ChatViewModel
implements ViewerScope by delegation and routes every ViewerEvent to the
slice reducer in one branch.

This is the template for splitting the rest of ChatViewModel. The scope
takes only what it reads from outside itself (the chat id, as a flow) and
can dispatch only its own events, so ViewerScopeTest binds it to a test
scope with a fake coordinator and no view model.

Getters that read only the slice (silence, canPin) move onto it; root
State keeps forwarders for them and for viewerState, so screen code is
unchanged. canSpeak, isReadOnlySpeaker and cashCardTap also read
membership, so they stay on State.

Behaviour is unchanged: the effect chains are moved as they were, and the
reducer still reads the wall clock.
@bmc08gt bmc08gt self-assigned this Nov 6, 2026
@github-actions github-actions Bot added the type: refactor Code restructuring, no behavior change label Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant