Skip to content

refactor: migrate RoomView to a function component#7482

Open
diegolmello wants to merge 245 commits into
developfrom
native-34-roomview-hooks
Open

refactor: migrate RoomView to a function component#7482
diegolmello wants to merge 245 commits into
developfrom
native-34-roomview-hooks

Conversation

@diegolmello

@diegolmello diegolmello commented Jul 9, 2026

Copy link
Copy Markdown
Member

Proposed changes

Migrates app/views/RoomView from a 1726-line class component to a function component. Behavior-preserving — no user-visible change intended.

  • Function component, no shouldComponentUpdate; connect(mapStateToProps) and all HOCs preserved.
  • Room/subscription observation moves into a rid-keyed, reference-counted RoomStore registry (stores/RoomStore.ts): self-hydrates from the DB, shared by a room and its threads, torn down on last release; goRoom warms it at nav time.
  • RoomContext replaced by a per-instance composer Zustand store (stores/ComposerStore.tsx).
  • Logic extracted into focused, unit-tested hooks (useRoomInit, useRoomAudioLifecycle, useRoomRemoved, useHeader, useJumpToMessage, useMessageActions, useOmnichannelPermissions, useRoomNavigation) and presentational components (MessageRow, RoomFooter, RoomMessageActions, room-state screens).
  • 'use memo' throughout so the React Compiler owns memoization.
  • SearchMessagesView results are wrapped in A11yGateProvider so long-press message actions work there too — a small a11y addition riding along with the shared message-handler extraction.

Issue(s)

https://rocketchat.atlassian.net/browse/NATIVE-34

How to test or reproduce

Use a room end to end (send/edit/quote/react, drafts, autocomplete, threads, jump-to-message, join a not-subscribed room, header actions) on phone and tablet, plus an omnichannel/livechat room. Behavior should match the base branch.

Screenshots

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works (if applicable)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

Stacked on native-22-message-hooks (PR #7455) and targets it until NATIVE-22 lands on develop, after which this will be rebased and retargeted to develop.

Summary by CodeRabbit

  • New Features
    • Jump-to-message now shows loading, supports cancel, and routes correctly between rooms and threads (including reply/thread context).
    • Room UI updates: improved header setup plus a dynamic footer and message actions (read-only, preview/join prompts, on-hold, blocked, federation messaging), with long-press actions and in-app feedback/haptics.
  • Documentation
    • Updated glossary for room/conversation viewing states and clarified message state responsibilities.
  • Tests
    • Added/expanded Jest coverage for jump-to-message, header setup, message actions, room/store behavior, and omnichannel permissions.

…state vocabulary

Rename UBIQUITOUS_LANGUAGE.md to CONTEXT.md (history preserved) and update its
three references in CLAUDE.md, the VoIP docs README, and the RoomView
architecture doc. Add the "Message Interaction & Position State" section
distinguishing Interaction state (selection plus the reply/quote/edit/react
action, owned by the per-Room interaction store) from Positional state
(highlight plus jump/scroll position, owned by the Room view scroll machinery),
since the two are migrated independently.

Ref: NATIVE-1345

Claude-Session: https://claude.ai/code/session_01Cj1jHhEbiC9mWGe8dpqQeS
Switch every @json-decorated WatermelonDB model field to the memoized form
({ memo: true }) so an unchanged nested JSON value keeps its reference identity
across reads, enabling per-field re-render bailout once consumers adopt it.

The memo cache is invalidated only when the raw column string changes, so
in-place mutation of a memoized value would silently corrupt it. Convert the
two remaining in-place mutations to replacement:
- ReactionsList sorts a copy ([...reactions].sort) instead of the field.
- encryption quote-attachment decryption reassigns message.attachments instead
  of pushing into it.

Ref: NATIVE-1346

Claude-Session: https://claude.ai/code/session_01Cj1jHhEbiC9mWGe8dpqQeS
Introduce useMessage, a reactive hook that takes a live WatermelonDB
Message model and returns a plain Message Snapshot via
useSyncExternalStore. It subscribes to model mutations, paints
synchronously on first render with no flash, and reads the memoized
@JSON getters directly so unchanged fields keep their reference for
per-field re-render bailout. All WatermelonDB specifics are confined
to the hook; it is not yet wired into the row container.
Convert MessageContainer from a class to a function component.

- Replace manual experimentalSubscribe + forceUpdate with the reactive
  useMessage snapshot hook; model mutations now re-render through
  useSyncExternalStore.
- Replace shouldComponentUpdate with React.memo and an equivalent custom
  comparator over the same nine props.
- Move isManualUnignored to useState, theming to useTheme, and
  defaultProps to destructuring parameter defaults.
- Keep the debounced onPress as a single lifetime instance via a ref,
  always invoking the latest closure.

Render output is unchanged: the existing Message snapshot suite passes
without updates. Adds a behavior test for the row container.
Move `action` and `selectedMessages` out of the RoomView class state into a
per-Room Zustand store (InteractionStore), mirroring the MessageComposer store
topology. Message rows now read their edit state via a `useIsBeingEdited`
selector instead of a prop, so starting or cancelling an edit re-renders only
the affected row instead of triggering a RoomView render and a FlatList walk.

Composer consumers read the interaction values from the store while keeping
their handlers on RoomContext, so the RoomContext value no longer changes when
interaction state changes. ShareView provides its own store for its composer
subtree. Behavior is unchanged.
Remove the umbrella IMessage/IMessageInner interfaces and the {...props}
bag spread from the message component tree. Each presentational component
now declares the narrow interface it consumes and receives explicit props.

Shared handlers (getCustomEmoji, navToRoomInfo, showAttachment, blockAction,
handleEnterCall, fetchThreadName) are read from MessageContext rather than
threaded as props, and MessageContext is now typed (IMessageContext) in
place of `any`. Behavior is unchanged.
…NATIVE-1351)

Add the `'use memo'` opt-in directive to the three presentational memo
components in Message.tsx (MessageInner, Message, MessageTouchable),
keeping their explicit React.memo gate. This mirrors the existing
precedent in message/index.tsx (MessageContainer) under the project's
React Compiler annotation mode.

Clean Message.stories.tsx to pass only the explicit data props each story
exercises: drop the per-story handler props (onReactionPress, onErrorPress,
replyBroadcast) and the dead `theme` prop, all of which are now supplied
through the MessageContext provider value. Behavior is unchanged — the
snapshot suite passes without updates.
Drop three confirmed-dead members from the RoomView container state: the
unused reaction-modal visibility flag (reactionsModalVisible), the unused
reply-with-mention flag (replyWithMention), and the redundant on-hold flag
(isOnHold).

reactionsModalVisible and replyWithMention were only ever initialized and
type-declared, never read or re-set. isOnHold was set alongside roomUpdate
and only compared in shouldComponentUpdate; that comparison is redundant
because roomUpdate already carries onHold (it is part of roomAttrsUpdate),
so on-hold transitions still trigger a re-render through the existing
roomUpdate diff. No behavior change — the full test suite passes without
snapshot updates.
Collect quote attachments from Promise.all and assign message.attachments
once after it resolves, instead of racing read-modify-write across the
concurrent decrypt branches (which dropped quotes on multi-quote messages).
Dead export with zero callers; consumers use the narrower hooks or read
getState().actions directly.
…reads

- Guard the model subscription so plain REST objects (search, starred,
  pinned, mentions, files) no longer throw 'experimentalSubscribe is not a
  function' on mount; serve a static snapshot when the model is not observable
- Remove render-body ref writes from MessageContainer and useMessage so the
  React Compiler memoizes both (stable subscribe/getSnapshot, snapshot cached
  by item identity; debounced press handler created once, latest closure via
  effect)
- areEqual compares previousItem.id (was ._id, always undefined on WatermelonDB
  models) and documents why item is intentionally omitted
- isMessageLink no longer treats plain links as message links when attachments
  is undefined
- read the translation from the useMessage snapshot instead of the live model
- drop redundant/historical comments

Adds a useMessage regression test for the non-observable plain-object path.
The message render tree is annotated for the React Compiler ('use memo').
A compiled parent emits referentially-stable child elements, so React.memo
on a child whose parent is also compiled is redundant. Remove the memo
wrappers and their hand-written comparators (both dequal deep-compares and
scalar shouldComponentUpdate-era allowlists) across the presentational
message components, leaving compilation as the single change-detection
mechanism.

The deep-compare removals are safe because the @JSON model fields
(attachments, urls, md, mentions, channels, reactions, replies) resolve to
stable references, making reference comparison equivalent to dequal.

MessageContainer (index.tsx) keeps its memo: its parent is RoomView's
renderItem class method, which the compiler never compiles, so that wrapper
is load-bearing.

Removing Thread's tcount-only comparator also fixes a latent bug: a tlm
change with unchanged tcount no longer suppresses the re-render that toggles
the thread button's visibility. Add Thread.test.tsx as a regression guard.

Also delete dead StyleSheet keys the memo wrappers had hidden from
react-native/no-unused-styles.
Message rendering funneled every field of a message through a single useMessage
hook that snapshotted the whole record and prop-drilled it down the subtree, so
a change to any one field re-rendered the entire message.

Introduce a per-message store (MessageStore) created once per MessageProvider and
ticked by the WatermelonDB record's experimentalSubscribe. Leaves read only the
fields they need through granular selector hooks that bail via Object.is for a
single field or useShallow for a domain group, so a field change re-renders just
its consumers. Delete useMessage and drop the props that existed only to carry
its snapshot.

Attachments and Quote keep attachments/author as props: Reply renders them
recursively with the nested attachment array, so a message-level hook there would
recurse infinitely.

Claude-Session: https://claude.ai/code/session_01Q27JMv1gF6V9urfyvGpWqn
…ubscription

Row grouping depended on the previous row's mutable scalars, propagated only
by the areEqual memo comparator — which cannot observe an in-place mutation of
a stable model instance, so a previous message going Sent to Error never
re-derived the next message's header until an unrelated re-render.

Subscribe each per-message store to both its own record and the previous
record; grouping and thread-position derivations read the previous model live.
Delete areEqual and the MessagePrev scalar snapshot; the row keeps memo with
the default comparator as a perf-only guard.

Claude-Session: https://claude.ai/code/session_01PNjqp2s36bgagFz7Lo6xsV
The Timestamp story renders a dayjs fromNow relative to the real current
date; the stored snapshot ('a year ago') drifted to '2 years ago',
failing the ESLint and Test job on every run after the boundary.

Claude-Session: https://claude.ai/code/session_01PNjqp2s36bgagFz7Lo6xsV
…-1376)

Adds pickMessageRoomState + a MessageProviders/renderWithMessageProviders helper and adopts it in the container test and stories so the room store is mounted alongside MessageContext during the dual-run. No production behavior change; 328 tests / 128 snapshots unchanged.
…ad (NATIVE-1376)

First stable-field consumer off MessageContext. RepliedThread now subscribes only to fetchThreadName (a stable ref) via useFetchThreadName(), so it no longer re-renders on this row's send/decrypt/status transitions.
…sageStore hooks (NATIVE-1376)

useMessageGrouping, useThreadPosition and useMessageText now read broadcast, Message_GroupingPeriod, isThreadRoom, user and auto-translate settings from the per-instance MessageRoomStore selectors instead of useContext(MessageContext). Consumer tests (Content, Thread, useMessageAccessibilityLabel, MessageStore) mount MessageRoomProvider alongside the legacy MessageContext.Provider, mirroring values via pickMessageRoomState during the dual-run migration.
… components (NATIVE-1376)

MessageAvatar, User, Urls, CallButton, Video, Audio and Quote now read user, navToRoomInfo, getCustomEmoji, handleEnterCall, baseUrl, rid (room store) plus id and translateLanguage (per-message store) through selector hooks instead of useContext(MessageContext). No test or story changes needed: these leaves render under Message.stories and the container test, which already mount MessageRoomProvider and MessageProvider.
…s and hooks (NATIVE-1376)

Attachments, Image/Container, CollapsibleQuote, useMediaAutoDownload and useMessageAccessibilityHint now read translateLanguage/id (per-message store) and user/baseUrl/showAttachment/getCustomEmoji/isThreadRoom (room store) via selector hooks instead of useContext(MessageContext). Their hand-written tests and stories mount MessageRoomProvider (and MessageProvider where a per-message hook is exercised) alongside the legacy MessageContext.Provider, mirroring values through pickMessageRoomState during the dual-run.
MessagePreview renders the message container outside RoomView/MessagesView/SearchMessagesView, which each already mount a MessageRoomProvider. After the message container's grouping/thread/translate hooks were repointed to the room store, this path had no provider and threw 'Message room hooks must be used within a MessageRoomProvider' on render, crashing ForwardMessageView. Seed a MessageRoomProvider from the props MessagePreview already holds (user, baseUrl, getCustomEmoji, rid); the remaining room fields stay unset to preserve the legacy preview defaults. Adds a regression test rendering MessagePreview under Redux only.
Blocks reads blockAction from the room store (useBlockAction); Reply reads user/baseUrl (room store) and id/isEncrypted (per-message store) via selector hooks instead of useContext(MessageContext). Drops the dead e2e read: the message MessageContext value built in index.tsx never sets e2e, so the guard (isEncrypted && !e2e) was always equivalent to isEncrypted. This retires the last stable/per-message-data consumers; only closure handlers remain on MessageContext.
…VE-1376)

Step 4a of ADR 0018. Adds the room-global row handlers (onReactionPress,
onReactionLongPress, reactionInit, onDiscussionPress, onThreadPress,
replyBroadcast, errorActionsShow, onAnswerButtonPress, onEncryptedPress) plus
archived to MessageRoomState, ROOM_STATE_KEYS and matching selector hooks, and
seeds them from RoomView's MessageRoomProvider. Purely additive: no consumer
reads them yet, MessageContext still carries the closures. MessagesView and
SearchMessagesView provide none of these handlers, so they stay unwired.
…IVE-1376)

Step 4b of ADR 0018. Extends the per-message zustand store with onPress,
onLongPress and threadBadgeColor (mirrored from MessageProvider props every
render, same pattern as MessageRoomProvider) and adds the composite hooks
useMessageLongPress, useMessagePress and useOnLinkPress as a faithful
relocation of the closures still living in MessageContainerInner. Adds
closeEmojiAndAction to the room store (it was RoomView-internal, never in
MessageContext) so useMessagePress can read it. Purely additive: the new hooks
have no consumers yet and MessageContainerInner still builds the legacy Context
closures, so behaviour and snapshots are unchanged.
…TIVE-1376)

Move the 8 remaining MessageContext consumers (Discussion, Reactions, Broadcast, AttachedActions, MessageError, Encrypted, Thread, Content) onto the room-level and per-message store selector hooks, reconstructing each handler's argument from the per-message context. Seed the per-message store's initial state from the provider's props so the first render sees onPress/onLongPress/threadBadgeColor synchronously, matching MessageRoomStore and avoiding an extra mount render. Rewire Reactions.test onto the shared MessageProviders helper for the dual-run providers.
…o store hooks (NATIVE-1376)

MessageContainer now renders the provider stack directly; the deleted MessageContainerInner's press and long-press logic moves into MessageTouchable via the useMessagePress and useMessageLongPress composite hooks instead of MessageContext. Relocate the manual-unignore state and the isBeingEdited lookup into MessageTouchable, the component that consumes them. RCTouchable reads the long-press guard from useMessageLongPress. MessageContext.Provider stays mounted with an empty value that no component reads, leaving step 5 a pure deletion. Wrap two attachment test harnesses in MessageProvider now that their Touchable subtree needs it.

@diegolmello diegolmello left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two-axis review (standards + spec) — findings inline. One non-inline note: the PR body lists useRoomLifecycle among the extracted hooks, but no hook by that name exists — lifecycle work landed as useRoomInit/useRoomAudioLifecycle/useRoomRemoved. Worth updating the body wording.

Comment thread app/views/RoomView/hooks/useMessageActions.tsx Outdated
Comment thread app/views/RoomView/hooks/useMessageActions.tsx Outdated
Comment thread app/views/RoomView/hooks/useRoomMessageHandlers.tsx Outdated
Comment thread app/views/RoomView/stores/ComposerStore.tsx
Comment thread app/views/RoomView/stores/RoomStore.ts
Comment thread app/views/RoomView/index.tsx Outdated
Comment thread app/views/SearchMessagesView/index.tsx
Comment thread app/views/RoomView/hooks/useRoomNavigation.ts Outdated
Comment thread app/views/RoomView/hooks/useMessageActions.tsx Outdated
Comment thread app/views/RoomView/hooks/useOmnichannelPermissions.ts
Comment thread app/views/RoomView/hooks/useRoomActions.ts Outdated
Comment thread app/views/RoomView/stores/RoomStore.ts Outdated
Comment thread app/views/RoomView/index.tsx Outdated
@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.75.0.109379

…attern

Extract useRoomWithUpdateFromStore primitive (holds the freshness comment once)
and route useRoomWithUpdate, useReadOnly, useE2EEStatus and useComposerRoom
through it, dropping the re-pasted room+roomUpdate double-subscribe. Split
useReadOnly into a useReadOnlyForStore core + thin context wrapper and give
useE2EEStatus the same optional-store override.
…parately

The on-hold effect re-fetched routing config (getRoutingConfig REST call)
on every on-hold/status transition. Fetch the config once (deps t/rid/joined)
into local state, and derive canPlaceLivechatOnHold in a separate deps-only
effect that reads the already-fetched config.
…active state

- type navToRoomInfo with IRoomInfoParam (drop any)
- fold view-level navToRoomInfo/showAttachment overrides into the handlers bag at
  provider construction so selectors read a single path
- split state into FrozenState/ReactiveState; ReactiveSnapshot forces the resync
  effect payload to cover every reactive key
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…rom JoinCode

Read master-detail via useMasterDetail() and take ref as a normal prop
(React 19 ref-as-prop). Removes the empty connect() and withMasterDetail HOC.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant