diff --git a/CONTEXT.md b/CONTEXT.md
index b6b803fe666..3224eedfa44 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -167,17 +167,19 @@ A **Message Action** is the active mode on a Message in the Room view. The three
## Omnichannel / Livechat
-| Term | Definition | Aliases to avoid |
-| ---------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------- |
-| **Omnichannel Room** | A customer-service Room (type `'l'`) connecting a Visitor to an Agent | Livechat room, support chat |
-| **Visitor** | An external customer who initiates an Omnichannel conversation, identified by a unique token | Client, customer, end-user |
-| **Agent** | A User designated to handle Omnichannel conversations, with `statusLivechat` (available/unavailable) | Support agent, operator, rep |
-| **Inquiry** | A queued Omnichannel request waiting to be picked up or routed to an Agent | Queue item, ticket |
-| **Department** | An organizational unit that groups Agents for Omnichannel routing | Team (ambiguous), group |
-| **Omnichannel Source** | How an Omnichannel conversation was initiated (widget, email, sms, app, api) | Channel origin |
-| **Served By** | The Agent currently assigned to handle an Omnichannel Room | Assigned agent, handler |
-| **On Hold** | An Omnichannel Room temporarily paused by the Agent | Paused, suspended |
-| **Transfer** | Moving an Omnichannel Room to a different Agent or Department | Forward, reassign, handoff |
+| Term | Definition | Aliases to avoid |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------- |
+| **Omnichannel Room** | A customer-service Room (type `'l'`) connecting a Visitor to an Agent | Livechat room, support chat |
+| **Visitor** | An external customer who initiates an Omnichannel conversation, identified by a unique token | Client, customer, end-user |
+| **Agent** | A User designated to handle Omnichannel conversations, with `statusLivechat` (available/unavailable) | Support agent, operator, rep |
+| **Inquiry** | A queued Omnichannel request waiting to be picked up or routed to an Agent | Queue item, ticket |
+| **Department** | An organizational unit that groups Agents for Omnichannel routing | Team (ambiguous), group |
+| **Omnichannel Source** | How an Omnichannel conversation was initiated (widget, email, sms, app, api) | Channel origin |
+| **Served By** | The Agent currently assigned to handle an Omnichannel Room | Assigned agent, handler |
+| **On Hold** | An Omnichannel Room temporarily paused by the Agent | Paused, suspended |
+| **Transfer** | Moving an Omnichannel Room to a different Agent or Department | Forward, reassign, handoff |
+| **Routing Config** | Per-server Omnichannel routing settings that say whether Agents may Return to Queue and see the Inquiry queue | Livechat config, routing settings |
+| **Return to Queue** | An Agent handing an Omnichannel Room back so it becomes an Inquiry again | Return inquiry, release chat |
## Encryption
diff --git a/app/actions/actionsTypes.ts b/app/actions/actionsTypes.ts
index 06445d9600e..1eae46f6e78 100644
--- a/app/actions/actionsTypes.ts
+++ b/app/actions/actionsTypes.ts
@@ -34,6 +34,7 @@ export const INQUIRY = createRequestTypes('INQUIRY', [
'QUEUE_UPDATE',
'QUEUE_REMOVE'
]);
+export const ROUTING_CONFIG = createRequestTypes('ROUTING_CONFIG');
export const APP = createRequestTypes('APP', [
'START',
'READY',
diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx
index 1a9fa2f9b6b..6805a659507 100644
--- a/app/containers/MessageComposer/MessageComposer.tsx
+++ b/app/containers/MessageComposer/MessageComposer.tsx
@@ -7,12 +7,10 @@ import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanima
import {
useComposerRid,
useComposerSharing,
- useComposerTmid,
- useEditRequest,
- useOnSendMessage,
- useSetQuotesAndText
+ useComposerStoreApi,
+ useComposerTmid
} from '../../views/RoomView/stores/ComposerStore';
-import { useEditingMessageId, useMessageActionKind, useQuotedMessageIds } from '../message/stores/MessageActionStore';
+import { useMessageActionKind, useMessageActionStoreApi } from '../message/stores/MessageActionStore';
import { Autocomplete } from './components';
import { MIN_HEIGHT } from './constants';
import {
@@ -63,12 +61,9 @@ export const MessageComposer = ({
const rid = useComposerRid();
const tmid = useComposerTmid();
const sharing = useComposerSharing();
- const editRequest = useEditRequest();
- const onSendMessage = useOnSendMessage();
- const setQuotesAndText = useSetQuotesAndText();
+ const composerStore = useComposerStoreApi();
+ const messageActionStore = useMessageActionStoreApi();
const actionKind = useMessageActionKind();
- const editingMessageId = useEditingMessageId();
- const quotedMessageIds = useQuotedMessageIds();
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
const { showEmojiKeyboard, showEmojiSearchbar, openEmojiSearchbar, resetKeyboard, keyboardHeight } = useEmojiKeyboard();
const { setAlsoSendThreadToChannel, setAutocompleteParams, clearAttachments } = useMessageComposerApi();
@@ -108,6 +103,11 @@ export const MessageComposer = ({
const handleSendMessage = async () => {
if (!rid) return;
+ const { editRequest, onSendMessage, setQuotesAndText } = composerStore.getState();
+ const { action } = messageActionStore.getState();
+ const editingMessageId = action?.kind === 'edit' ? action.messageId : undefined;
+ const quotedMessageIds = action?.kind === 'quote' ? action.messageIds : [];
+
if (alsoSendThreadToChannel) {
setAlsoSendThreadToChannel(false);
}
diff --git a/app/containers/message/docs/ARCHITECTURE.md b/app/containers/message/docs/ARCHITECTURE.md
deleted file mode 100644
index d888b33ac6b..00000000000
--- a/app/containers/message/docs/ARCHITECTURE.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# Message Component Architecture
-
-Load-bearing reference for `app/containers/message/`: how one Message row is provided, composed, and rendered. Domain terms (**Message Action**, **Message Action State**, **Positional State**, **Message Preview**) are defined in the repo glossary `CONTEXT.md` — this document uses them as defined there. Post-refactor (PR #7455): the module moved from a single class-component prop chain to three scoped Zustand stores plus a function-component render tree.
-
----
-
-## Purpose & entry point
-
-The module renders **one Message row**. The public entry point is the default export of `app/containers/message/index.tsx`:
-
-```tsx
-
-
-
-
-```
-
-`MessageProvider` seeds the per-row `MessageStore` (see below); `MessageTouchable` is the pressable row itself; `MessageSeparator` (a sibling, not part of this module) renders the date/unread divider above the row.
-
-This default export is consumed as `renderRow` from three list contexts — `RoomView` (the main Message Window), `MessagesView` (pinned/starred lists), `SearchMessagesView` (search results) — plus one non-list context: `components/Preview.tsx` wraps a single Message with its own `MessageRoomProvider` for a **Message Preview** rendered outside a Room (forward-message target picker, quoted-message preview in markdown). A `highlighted` boolean and `isPreview` flag are the only pieces of **Positional State** and preview-mode state this module receives from its caller — the Message Window itself (jump target, scroll position) is owned by `RoomView`, not here.
-
----
-
-## The three stores
-
-| Store | File | Scope | Provider mounted at | Lifetime |
-| ---------------------- | ------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------- |
-| **MessageStore** | `stores/MessageStore.tsx` | One Message record (`item`) + its previous neighbor | `index.tsx`, once per row | Row mount → unmount |
-| **MessageRoomStore** | `stores/MessageRoomStore.tsx` | Room-scoped context: handlers, `rid`, `baseUrl`, user, settings | `RoomView`, `MessagesView`, `SearchMessagesView`, `ModalBlockView`, `Preview.tsx` — wraps the whole list/row | Room/view mount → unmount |
-| **MessageActionStore** | `stores/MessageActionStore.tsx` | The active **Message Action** (Quote/Edit/React) and its target(s) | `RoomView` only, via `RoomProviders` — one per Room, wraps List **and** the composer | Room mount → unmount |
-
-### Why three, not one
-
-Each store changes at a different frequency and has a different audience, and collapsing them would force every row to re-render on changes it doesn't care about:
-
-- **MessageStore** changes per-row, on that row's own WatermelonDB record emitting (`experimentalSubscribe`) — a `tick` counter bumps and only that row's selectors re-run. `previousItem` doubles as the neighbor lookup used by grouping/thread-position logic.
-- **MessageRoomStore** is mostly static for the life of a Room: navigation callbacks, `rid`, `baseUrl`, and the logged user are captured once (see `FROZEN_KEYS`) and never resynced, because they're expected to be referentially stable for the Room's lifetime. A second, explicitly reactive tail (`timeFormat`, `autoTranslateRoom`, `autoTranslateLanguage`, `archived`, `broadcast`, `isReadReceiptEnabled`, `Message_GroupingPeriod`) is pushed on every render via a dependency-gated `useEffect`, because those _can_ change mid-session (e.g. a Room gets archived).
-- **MessageActionStore** is shared Room-wide state that both the Message list (to highlight the row being edited) and `MessageComposer` (to know what's being quoted/edited) read — it has to live above both, not per-row.
-
-### Granular selectors + `useShallow` + React Compiler
-
-Every store exposes narrow, single-purpose hooks (`useReactions`, `useBlocks`, `useMessageAuthor`, `useIsEncrypted`, …) instead of one broad "give me the message" hook — each leaf component subscribes to only the fields it renders, so a field it doesn't use changing elsewhere on the record never re-renders it. Hooks returning more than one field use Zustand's `useShallow` so a same-value re-tick doesn't produce a new object reference (`useBlocks`, `useThreadData`, `useMessageAuthor`, `useContentData`, etc.). Memoization comes from React Compiler (`babel-plugin-react-compiler`, `compilationMode: 'infer'`) rather than hand-written `useMemo`/`useCallback`.
-
-Two dev-only guards protect this pattern from silent regressions (both no-op in production builds):
-
-- `useMessageFieldDev` (MessageStore) warns once if a `useMessageField` selector returns a fresh-but-shallow-equal object/array on an unchanged tick — a sign someone wrote an inline `item => ({ a: item.a })` instead of a `useShallow` domain hook. Field stability itself depends on the model's `@json(..., { memo: true })` decorators (`app/lib/database/model/Message.js`), guarded separately by `Message.memo.test.ts`.
-- `useFrozenHandlersGuardDev` (MessageRoomStore) warns once if any `FROZEN_KEYS` value's identity changes after mount, since the provider captures those once and never re-syncs them.
-
-### Inert-store fallback vs. throwing
-
-Rows can render outside a full provider stack (a **Message Preview** has no `MessageActionProvider`; some tests mount only `MessageStore`/`MessageRoomStore`). The three stores disagree deliberately on how to fail:
-
-- **MessageStore** and **MessageRoomStore** hooks throw (`'Message hooks must be used within a MessageProvider'` / `'...MessageRoomProvider'`) if no provider is present — every row and every Room-scoped view is expected to always supply both.
-- **MessageActionStore** distinguishes by hook: `useMessageAction()` throws without a provider, but `useIsBeingEdited(messageId)` falls back to a shared `inertStore` (`action: null`, and every action method throws `NO_OP_ACTIONS`) — a Message Preview rendered without a `MessageActionProvider` can ask "am I being edited?" and safely get `false` instead of crashing, since it can never actually enter an edit session.
-
----
-
-## Component composition
-
-```mermaid
-flowchart TD
- A["MessageContainer (index.tsx)"] --> B["MessageProvider (MessageStore)"]
- B --> C["MessageTouchable"]
- C -->|"hasError / isInfo"| D["Message (bare, no Touch wrapper)"]
- C -->|otherwise| E["Touch (press/long-press gate + a11y)"]
- E --> D
- D -->|"isThreadReply / isThreadSequential / isInfo / isIgnored"| F["CompactMessage"]
- D -->|otherwise| G["FullMessage"]
- F --> H["MessageAvatar (small) + Content [+ Attachments if pinned info]"]
- G --> I["MessageAvatar + Layout + RightIcons (hidden when grouped)"]
- I --> J["Layout dispatch"]
- J -->|blocks| K["BlocksLayout"]
- J -->|jitsi_call_started| L["JitsiLayout"]
- J -->|discussion-created| M["DiscussionLayout"]
- J -->|isPreview| N["PreviewLayout"]
- J -->|default| O["StandardLayout"]
- O --> P["User + Time + ContentLayout + Thread + Reactions + Broadcast"]
- P --> Q["Content dispatch"]
- Q -->|isInfo| R["InfoContent"]
- Q -->|isIgnored| S["IgnoredContent"]
- Q -->|isEncrypted| T["EncryptedContent"]
- Q -->|"tmid, not thread room"| U["PreviewContent"]
- Q -->|has text| V["MarkdownContent"]
- O --> W["Attachments (Image/Audio/Video/AttachedActions/CollapsibleQuote/Reply)"]
-```
-
-- **MessageTouchable** (`components/Touchable/MessageTouchable.tsx`) is the press/long-press gate and the a11y surface for the row: it reads `useMessageTouchable()`'s derived `tappable`/`longPressable`/`revealsIgnored` flags plus `useIsBeingEdited` (for the edit-mode highlight color) and builds the accessibility label/hint/actions. Error and Info Messages skip the `Touch` wrapper entirely — the whole row isn't pressable, though sub-elements (links, attachments) still are through their own handlers.
-- **`Touch`** (`components/Touchable/Touch.tsx`) is the actual platform touchable (`TouchableOpacity` on iOS, `TouchableHighlight` on Android) wrapped for `react-native-external-keyboard` D-pad focus; it carries no Message-specific logic.
-- **`Message`** (`components/Message/Message.tsx`) is the density switch: `CompactMessage` for a **Thread Message**'s in-parent-room preview (`isThreadReply`/`isThreadSequential`), an **Info Message**, or an Ignored Message; `FullMessage` otherwise.
-- **`FullMessage`** renders the avatar, dispatches to `Layout`, and appends `RightIcons` only when the row shows its own **Message Header** (`!isHeader` from `useMessageGrouping`) — a **Grouped Message** has no right-icon row.
-- **`CompactMessage`** renders a small avatar and `Content` directly (no `Layout` dispatch), with an extra read-only `Attachments` render for a pinned Info Message (`t === 'message_pinned'`) and an optional `RepliedThread` header when it's the first Thread Message in a run.
-- **`Layout`** (`components/Layout/index.tsx`) picks among `BlocksLayout` (a Blocks Message from an App), `JitsiLayout`/`DiscussionLayout` (their respective System Message types), `PreviewLayout` (a **Message Preview**), and `StandardLayout` (everything else), all wrapped in `WidthAwareView` for width-dependent children (images, markdown wrapping).
-- **`Content`** (`components/Content/index.tsx`) is a second, narrower dispatch inside `StandardLayout`/`PreviewLayout`/`CompactMessage` for the body itself: Info → Ignored → Encrypted → thread-preview (`PreviewContent`, only outside a Thread room) → markdown, in that priority order.
-- **`Attachments`** (`components/Attachments/Attachments.tsx`) filters by `variant` — `content` (the default) keeps non-quote attachments and maps each to `Image`/`Audio`/`Video`/`AttachedActions`/`CollapsibleQuote`/`Reply` by shape (`image_url`, `audio_url`, `video_url`, `actions`, `collapsed`, nested `attachments`), `quote` keeps quote attachments and renders each as a `Reply`. It owns the nested-quote recursion: `Reply` takes the nested lists as `children`, so the recursion stays inside this one module. Callers pick the variant directly; there is no separate `Quote` component.
-- **`RightIcons`** (`components/RightIcons/index.tsx`) is a flat row of independent flag icons — `Pinned`, `Encrypted`, `Edited`, `MessageError`, `Translated`, `ReadReceipt` — each reading its own MessageStore/MessageRoomStore slice.
-- **`MessageActionTouchable`** (`components/Touchable/MessageActionTouchable.tsx`) is a smaller building block (a `Pressable` wired to `useMessageLongPress`) reused by several leaf components (Reactions, Thread, Discussion, Broadcast, attachment sub-views) that need the row's long-press behavior without being the row's own top-level touchable.
-
----
-
-## Vocabulary alignment
-
-`TMessageActionState` (`definitions/IMessage.ts`) is `{ kind: 'edit' | 'quote' | 'react', ... } | null` — this matches CONTEXT.md's **Message Action** definition exactly (Quote, Edit, React; no "reply" action). `MessageActionStore`'s `action` field is the **Message Action State** as defined in the glossary; the retired term "interaction" does not appear anywhere in this module's stores or hooks. **Positional State** (highlight, jump/scroll position) is not owned here — it arrives as the plain `highlighted` prop threaded down from `RoomView`'s list.
-
-**Known follow-up:** `MessageRoomState`'s handler/constant fields are all individually optional (`?`), which is looser than the shape the values actually have once mounted (all frozen fields are supplied together). A future pass may flatten this to a non-optional shape post-mount; as of this refactor it remains all-optional.
diff --git a/app/containers/message/stores/MessageActionStore.tsx b/app/containers/message/stores/MessageActionStore.tsx
index c363bc4ef2c..fd415da8b00 100644
--- a/app/containers/message/stores/MessageActionStore.tsx
+++ b/app/containers/message/stores/MessageActionStore.tsx
@@ -53,32 +53,19 @@ export type TMessageActionStore = ReturnType;
export const MessageActionStoreContext = createContext(null);
-const noOpAction = (): never => {
- throw new Error('MessageActionStore: no provider — actions unavailable');
-};
-
-const NO_OP_ACTIONS: TMessageActionActions = {
- startEditing: noOpAction,
- startQuote: noOpAction,
- addQuote: noOpAction,
- removeQuote: noOpAction,
- startReacting: noOpAction,
- setQuoteMessageIds: noOpAction,
- clear: noOpAction
-};
+const inertStore = createStore>()(() => ({ action: null }));
-// Rows rendered outside a RoomView (search, pinned) can never be in edit mode.
-// `action` is fixed at null (no `set` param) and its actions always throw — a safe, inert fallback.
-export const inertStore = createStore()(() => ({ action: null, actions: NO_OP_ACTIONS }));
-
-const useMessageActionStore = (selector: (state: MessageActionState) => T): T => {
+export const useMessageActionStoreApi = (): TMessageActionStore => {
const store = useContext(MessageActionStoreContext);
if (!store) {
throw new Error('Message action hooks must be used within a MessageActionProvider');
}
- return useStore(store, selector);
+ return store;
};
+const useMessageActionStore = (selector: (state: MessageActionState) => T): T =>
+ useStore(useMessageActionStoreApi(), selector);
+
// `action` is a single ref replaced wholesale on every `set` — no useShallow needed.
export const useMessageAction = (): TMessageActionState => useMessageActionStore(s => s.action);
@@ -91,7 +78,10 @@ export const useMessageActionKind = (): NonNullable['kind']
*/
export const useIsBeingEdited = (messageId: string): boolean => {
const store = useContext(MessageActionStoreContext) ?? inertStore;
- return useStore(store, s => s.action?.kind === 'edit' && s.action.messageId === messageId);
+ return useStore(
+ store,
+ (s: Pick) => s.action?.kind === 'edit' && s.action.messageId === messageId
+ );
};
// Stable ref so the selector doesn't emit a fresh array when not quoting.
diff --git a/app/containers/message/stores/MessageRoomStore.tsx b/app/containers/message/stores/MessageRoomStore.tsx
index 4d69bd17845..8c204c29a45 100644
--- a/app/containers/message/stores/MessageRoomStore.tsx
+++ b/app/containers/message/stores/MessageRoomStore.tsx
@@ -1,31 +1,25 @@
-import { createContext, useContext, useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react';
+import { createContext, useContext, useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react';
import { createStore, useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { type IUseRoomMessageHandlersResult, type IUser, type TAnyMessageModel } from '../../../definitions';
import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { useLiveRef } from '../../../lib/hooks/useLiveRef';
import { useSetting } from '../../../lib/hooks/useSetting';
import { getUserSelector } from '../../../selectors/login';
-// Captured once at mount by the provider (handlers + room constants); callers MUST pass
-// referentially stable values (see FROZEN_KEYS / the dev guard below).
-type FrozenState = {
- // stable handlers
+type LiveCallbacks = {
jumpToMessage?: (link: string) => void;
closeEmojiAndAction?: (action?: (params?: unknown) => void, params?: unknown) => void;
- // row action handlers
reactionInit?: (messageId: string) => void;
errorActionsShow?: (item: TAnyMessageModel) => void;
- // room constants
- rid?: string;
- isThreadRoom?: boolean;
- tmid?: string;
};
-// Reactive tail: can change mid-session (e.g. an open room gets archived); the provider resyncs
-// these into the store on change (see the effect below).
-type ReactiveState = {
+export type MessageRoomState = LiveCallbacks & {
handlers?: Partial;
+ rid?: string;
+ isThreadRoom?: boolean;
+ tmid?: string;
archived?: boolean;
broadcast?: boolean;
isReadReceiptEnabled?: boolean;
@@ -35,11 +29,9 @@ type ReactiveState = {
autoTranslateLanguage?: string;
};
-export type MessageRoomState = FrozenState & ReactiveState;
+type MessageRoomSnapshot = { [K in keyof Required]: MessageRoomState[K] };
-// Exact reactive snapshot: `-?` forces every reactive key to be present, so the resync effect's
-// setState payload can never silently drift from ReactiveState.
-type ReactiveSnapshot = { [K in keyof Required]: ReactiveState[K] };
+type LiveCallbacksSnapshot = { [K in keyof Required]: LiveCallbacks[K] };
export const createMessageRoomStore = (initial: MessageRoomState) => createStore(() => ({ ...initial }));
@@ -55,52 +47,45 @@ const useMessageRoomStore = (selector: (state: MessageRoomState) => T): T =>
return useStore(store, selector);
};
-// Handlers and room constants captured once at mount by MessageRoomStoreProvider below (everything
-// outside the reactive-tail resync effect). Callers MUST pass referentially stable values for these.
-const FROZEN_KEYS = [
- 'jumpToMessage',
- 'closeEmojiAndAction',
- 'reactionInit',
- 'errorActionsShow',
- 'rid',
- 'isThreadRoom',
- 'tmid'
-] as const satisfies readonly (keyof MessageRoomState)[];
-
-const useFrozenHandlersGuardProd = (_state: MessageRoomState): void => {};
-
-// Warns once (after mount) if a frozen handler/constant's identity changes, since the provider
-// only captures the initial value and never re-syncs it (see FROZEN_KEYS above).
-const useFrozenHandlersGuardDev = (state: MessageRoomState): void => {
- const initialRef = useRef(state);
- const warnedRef = useRef(false);
- useEffect(() => {
- if (warnedRef.current) return;
- const changed = FROZEN_KEYS.filter(key => !Object.is(initialRef.current[key], state[key]));
- if (changed.length > 0) {
- warnedRef.current = true;
- console.warn(
- `[MessageRoomStore] handler/constant identity changed after mount for: ${changed.join(', ')}. ` +
- 'MessageRoomProvider captures these once; pass referentially stable values.'
- );
- }
- });
+const useLiveCallbacks = (callbacks: LiveCallbacks): LiveCallbacksSnapshot => {
+ const latest = useLiveRef(callbacks);
+ const [wrappers] = useState>(() => ({
+ jumpToMessage: (...args) => latest.current.jumpToMessage?.(...args),
+ closeEmojiAndAction: (...args) => latest.current.closeEmojiAndAction?.(...args),
+ reactionInit: (...args) => latest.current.reactionInit?.(...args),
+ errorActionsShow: (...args) => latest.current.errorActionsShow?.(...args)
+ }));
+
+ const hasJumpToMessage = !!callbacks.jumpToMessage;
+ const hasCloseEmojiAndAction = !!callbacks.closeEmojiAndAction;
+ const hasReactionInit = !!callbacks.reactionInit;
+ const hasErrorActionsShow = !!callbacks.errorActionsShow;
+
+ return useMemo(
+ () => ({
+ jumpToMessage: hasJumpToMessage ? wrappers.jumpToMessage : undefined,
+ closeEmojiAndAction: hasCloseEmojiAndAction ? wrappers.closeEmojiAndAction : undefined,
+ reactionInit: hasReactionInit ? wrappers.reactionInit : undefined,
+ errorActionsShow: hasErrorActionsShow ? wrappers.errorActionsShow : undefined
+ }),
+ [wrappers, hasJumpToMessage, hasCloseEmojiAndAction, hasReactionInit, hasErrorActionsShow]
+ );
};
-const useFrozenHandlersGuard: (state: MessageRoomState) => void = __DEV__
- ? useFrozenHandlersGuardDev
- : useFrozenHandlersGuardProd;
-
-const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
- const [store] = useState(() => createMessageRoomStore(state));
- useFrozenHandlersGuard(state);
+export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
+ const timeFormatSetting = useSetting('Message_TimeFormat') as string;
+ const timeFormat = state.timeFormat ?? timeFormatSetting;
+ const callbacks = useLiveCallbacks(state);
+ const [store] = useState(() => createMessageRoomStore({ ...state, ...callbacks, timeFormat }));
- // These fields can change mid-session (e.g. an open room gets archived), unlike the
- // constants/handlers captured once above. The dep array keeps store writes on-change only.
useEffect(() => {
- const reactiveState: ReactiveSnapshot = {
+ const snapshot: MessageRoomSnapshot = {
+ ...callbacks,
handlers: state.handlers,
- timeFormat: state.timeFormat,
+ rid: state.rid,
+ isThreadRoom: state.isThreadRoom,
+ tmid: state.tmid,
+ timeFormat,
autoTranslateRoom: state.autoTranslateRoom,
autoTranslateLanguage: state.autoTranslateLanguage,
archived: state.archived,
@@ -108,10 +93,14 @@ const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode
isReadReceiptEnabled: state.isReadReceiptEnabled,
Message_GroupingPeriod: state.Message_GroupingPeriod
};
- store.setState(reactiveState);
+ store.setState(snapshot);
}, [
+ callbacks,
state.handlers,
- state.timeFormat,
+ state.rid,
+ state.isThreadRoom,
+ state.tmid,
+ timeFormat,
state.autoTranslateRoom,
state.autoTranslateLanguage,
state.archived,
@@ -124,24 +113,6 @@ const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode
return {children};
};
-const MessageRoomProviderWithSetting = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
- const Message_TimeFormat = useSetting('Message_TimeFormat') as string;
-
- return (
-
- {children}
-
- );
-};
-
-export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
- return state.timeFormat != null ? (
- {children}
- ) : (
- {children}
- );
-};
-
export const useJumpToMessage = (): ((link: string) => void) | undefined => useMessageRoomStore(s => s.jumpToMessage);
export const useCloseEmojiAndAction = (): MessageRoomState['closeEmojiAndAction'] =>
useMessageRoomStore(s => s.closeEmojiAndAction);
diff --git a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx
index 0450dfea9e4..7746921db11 100644
--- a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx
+++ b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx
@@ -2,7 +2,6 @@ import { render } from '@testing-library/react-native';
import {
createMessageActionStore,
- inertStore,
MessageActionProvider,
useEditingMessageId,
useIsBeingEdited,
@@ -23,7 +22,7 @@ describe('MessageActionStore', () => {
expect(spy).toHaveBeenLastCalledWith(false);
});
- it('does not share mutable state across separate no-provider renders, and its actions throw', () => {
+ it('does not share mutable state across separate no-provider renders', () => {
const spyA = jest.fn();
const spyB = jest.fn();
const ProbeA = () => {
@@ -40,9 +39,6 @@ describe('MessageActionStore', () => {
expect(spyA).toHaveBeenLastCalledWith(false);
expect(spyB).toHaveBeenLastCalledWith(false);
- expect(() => inertStore.getState().actions.startEditing('x')).toThrow(
- 'MessageActionStore: no provider — actions unavailable'
- );
});
it('returns true only for the message being edited under a provider', () => {
diff --git a/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx b/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx
index 3803d8e615d..b284a1faac7 100644
--- a/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx
+++ b/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx
@@ -1,14 +1,16 @@
import { memo } from 'react';
-import { act, render } from '@testing-library/react-native';
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
import { Text } from 'react-native';
import { Provider } from 'react-redux';
import {
MessageRoomProvider,
+ type MessageRoomState,
useAutoTranslate,
useBlockAction,
useIsArchived,
useNavToRoomInfo,
+ useReactionInit,
useShowAttachment,
useTimeFormat
} from '../MessageRoomStore';
@@ -230,48 +232,61 @@ describe('MessageRoomStore', () => {
});
});
- describe('frozen handler guard (dev)', () => {
- let warnSpy: jest.SpyInstance;
+ describe('live callbacks', () => {
+ const wrap = (config: Partial) => (
+
+
+
+
+
+ );
- beforeEach(() => {
- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const renderSpy = jest.fn();
+ const ReactionInitConsumer = memo(() => {
+ const reactionInit = useReactionInit();
+ renderSpy(reactionInit);
+ return reactionInit?.('message-id')}>reaction;
});
- afterEach(() => {
- warnSpy.mockRestore();
+ beforeEach(() => renderSpy.mockClear());
+
+ it('invokes the latest callback after a rerender', () => {
+ const first = jest.fn();
+ const second = jest.fn();
+
+ const { rerender } = render(wrap({ reactionInit: first }));
+ act(() => rerender(wrap({ reactionInit: second })));
+
+ fireEvent.press(screen.getByText('reaction'));
+
+ expect(second).toHaveBeenCalledWith('message-id');
+ expect(first).not.toHaveBeenCalled();
});
- it('warns once when a frozen handler identity changes after mount', () => {
- const wrap = (reactionInit: () => void) => (
-
-
- child
-
-
- );
+ it('keeps the callback identity stable across rerenders', () => {
+ const { rerender } = render(wrap({ reactionInit: jest.fn() }));
+ const renderCallsBefore = renderSpy.mock.calls.length;
- const { rerender } = render(wrap(() => {}));
- expect(warnSpy).not.toHaveBeenCalled();
+ act(() => rerender(wrap({ reactionInit: jest.fn() })));
- act(() => rerender(wrap(() => {})));
+ expect(renderSpy.mock.calls.length).toBe(renderCallsBefore);
+ });
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('reactionInit'));
+ it('stays undefined when the provider does not supply the callback', () => {
+ render(wrap({}));
+
+ expect(renderSpy).toHaveBeenLastCalledWith(undefined);
});
- it('does not warn when the same handler reference is passed across re-renders', () => {
- const reactionInit = () => {};
- const wrap = () => (
-
-
- child
-
-
- );
+ it('becomes defined when the provider starts supplying the callback', () => {
+ const reactionInit = jest.fn();
+ const { rerender } = render(wrap({}));
+ expect(renderSpy).toHaveBeenLastCalledWith(undefined);
- const { rerender } = render(wrap());
- act(() => rerender(wrap()));
+ act(() => rerender(wrap({ reactionInit })));
+ fireEvent.press(screen.getByText('reaction'));
- expect(warnSpy).not.toHaveBeenCalled();
+ expect(reactionInit).toHaveBeenCalledWith('message-id');
});
});
});
diff --git a/app/definitions/redux/index.ts b/app/definitions/redux/index.ts
index f32d7041e69..c20408dea7f 100644
--- a/app/definitions/redux/index.ts
+++ b/app/definitions/redux/index.ts
@@ -1,5 +1,6 @@
// ACTIONS
import { type TActionInquiry } from '../../ee/omnichannel/actions/inquiry';
+import { type TActionRoutingConfig } from '../../ee/omnichannel/actions/routingConfig';
import { type TActionActiveUsers } from '../../actions/activeUsers';
import { type TActionApp } from '../../actions/app';
import { type TActionCreateChannel } from '../../actions/createChannel';
@@ -35,6 +36,7 @@ import { type IServer } from '../../reducers/server';
import { type TSettingsState } from '../../reducers/settings';
import { type IShare } from '../../reducers/share';
import { type IInquiry } from '../../ee/omnichannel/reducers/inquiry';
+import { type IRoutingConfig } from '../../ee/omnichannel/reducers/routingConfig';
import { type IPermissionsState } from '../../reducers/permissions';
import { type IEnterpriseModules } from '../../reducers/enterpriseModules';
import { type IVideoConf } from '../../reducers/videoConf';
@@ -67,6 +69,7 @@ export interface IApplicationState {
inviteLinks: IInviteLinks;
createDiscussion: ICreateDiscussion;
inquiry: IInquiry;
+ routingConfig: IRoutingConfig;
enterpriseModules: IEnterpriseModules;
encryption: IEncryption;
permissions: IPermissionsState;
@@ -93,6 +96,7 @@ export type TApplicationActions = TActionActiveUsers &
TActionServer &
TActionApp &
TActionInquiry &
+ TActionRoutingConfig &
TActionPermissions &
TActionEnterpriseModules &
TActionVideoConf &
diff --git a/app/ee/omnichannel/actions/routingConfig.ts b/app/ee/omnichannel/actions/routingConfig.ts
new file mode 100644
index 00000000000..f1701f52ab9
--- /dev/null
+++ b/app/ee/omnichannel/actions/routingConfig.ts
@@ -0,0 +1,25 @@
+import { type Action } from 'redux';
+
+import { ROUTING_CONFIG } from '../../../actions/actionsTypes';
+
+interface IRoutingConfigSuccess extends Action {
+ returnQueue: boolean;
+}
+
+interface IRoutingConfigFailure extends Action {
+ error: unknown;
+}
+
+export type TActionRoutingConfig = Action & Partial;
+
+export function routingConfigRequest(): Action {
+ return { type: ROUTING_CONFIG.REQUEST };
+}
+
+export function routingConfigSuccess(returnQueue: boolean): IRoutingConfigSuccess {
+ return { type: ROUTING_CONFIG.SUCCESS, returnQueue };
+}
+
+export function routingConfigFailure(error: unknown): IRoutingConfigFailure {
+ return { type: ROUTING_CONFIG.FAILURE, error };
+}
diff --git a/app/ee/omnichannel/hooks/useCanReturnQueue.test.tsx b/app/ee/omnichannel/hooks/useCanReturnQueue.test.tsx
new file mode 100644
index 00000000000..2fc974f8da1
--- /dev/null
+++ b/app/ee/omnichannel/hooks/useCanReturnQueue.test.tsx
@@ -0,0 +1,42 @@
+import { renderHook, waitFor } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+
+jest.mock('../../../lib/services/restApi', () => ({ getRoutingConfig: jest.fn() }));
+
+import { getRoutingConfig } from '../../../lib/services/restApi';
+import routingConfigSaga from '../sagas/routingConfig';
+import { createRecordingStore, cancelSagaTasks } from '../../../lib/testUtils/sagaStore';
+import { useCanReturnQueue } from './useCanReturnQueue';
+import type { ReactNode } from 'react';
+
+const mockGetRoutingConfig = getRoutingConfig as jest.MockedFunction;
+
+afterEach(() => {
+ cancelSagaTasks();
+ jest.clearAllMocks();
+});
+
+const createWrapper = () => {
+ const recording = createRecordingStore(routingConfigSaga);
+ const Wrapper = ({ children }: { children: ReactNode }) => {children};
+ return { ...recording, Wrapper };
+};
+
+it('does not request when disabled and stays false while unloaded', () => {
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useCanReturnQueue(false), { wrapper: Wrapper });
+
+ expect(result.current).toBe(false);
+ expect(mockGetRoutingConfig).not.toHaveBeenCalled();
+});
+
+it('requests once when two consumers mount together', async () => {
+ mockGetRoutingConfig.mockResolvedValue({ returnQueue: true } as never);
+ const { Wrapper } = createWrapper();
+ const first = renderHook(() => useCanReturnQueue(true), { wrapper: Wrapper });
+ const second = renderHook(() => useCanReturnQueue(true), { wrapper: Wrapper });
+
+ await waitFor(() => expect(first.result.current).toBe(true));
+ expect(second.result.current).toBe(true);
+ expect(mockGetRoutingConfig).toHaveBeenCalledTimes(1);
+});
diff --git a/app/ee/omnichannel/hooks/useCanReturnQueue.ts b/app/ee/omnichannel/hooks/useCanReturnQueue.ts
new file mode 100644
index 00000000000..0536d767358
--- /dev/null
+++ b/app/ee/omnichannel/hooks/useCanReturnQueue.ts
@@ -0,0 +1,18 @@
+import { useEffect } from 'react';
+import { useDispatch } from 'react-redux';
+
+import { routingConfigRequest } from '../actions/routingConfig';
+import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+
+export function useCanReturnQueue(enabled: boolean): boolean {
+ const dispatch = useDispatch();
+ const returnQueue = useAppSelector(state => state.routingConfig.returnQueue);
+
+ useEffect(() => {
+ if (enabled && returnQueue === null) {
+ dispatch(routingConfigRequest());
+ }
+ }, [dispatch, enabled, returnQueue]);
+
+ return enabled && returnQueue === true;
+}
diff --git a/app/ee/omnichannel/lib/__tests__/takeInquiry.test.ts b/app/ee/omnichannel/lib/__tests__/takeInquiry.test.ts
new file mode 100644
index 00000000000..5ab52a63152
--- /dev/null
+++ b/app/ee/omnichannel/lib/__tests__/takeInquiry.test.ts
@@ -0,0 +1,38 @@
+import sdk from '../../../../lib/services/sdk';
+import { store as reduxStore } from '../../../../lib/store/auxStore';
+import { takeInquiry } from '..';
+
+jest.mock('../../../../lib/services/sdk', () => ({
+ __esModule: true,
+ default: { post: jest.fn(() => Promise.resolve()), get: jest.fn(), methodCallWrapper: jest.fn(() => Promise.resolve()) }
+}));
+jest.mock('../../../../lib/store/auxStore', () => ({ store: { getState: jest.fn() } }));
+jest.mock('../subscriptions/inquiry', () => ({ __esModule: true, default: jest.fn() }));
+jest.mock('../../../../lib/methods/helpers/events', () => ({
+ __esModule: true,
+ default: { addEventListener: jest.fn(), removeListener: jest.fn() }
+}));
+
+const mockGetState = reduxStore.getState as jest.Mock;
+
+describe('takeInquiry', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('uses the REST endpoint on a modern Workspace', async () => {
+ mockGetState.mockReturnValue({ server: { version: '7.11.0' } });
+
+ await takeInquiry('inquiry-1');
+
+ expect(sdk.post).toHaveBeenCalledWith('livechat/inquiries.take', { inquiryId: 'inquiry-1' });
+ expect(sdk.methodCallWrapper).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the DDP method on an older Workspace', async () => {
+ mockGetState.mockReturnValue({ server: { version: '7.10.0' } });
+
+ await takeInquiry('inquiry-1');
+
+ expect(sdk.methodCallWrapper).toHaveBeenCalledWith('livechat:takeInquiry', 'inquiry-1');
+ expect(sdk.post).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/ee/omnichannel/lib/index.ts b/app/ee/omnichannel/lib/index.ts
index 60308b3cb43..a3afef65b9e 100644
--- a/app/ee/omnichannel/lib/index.ts
+++ b/app/ee/omnichannel/lib/index.ts
@@ -28,7 +28,8 @@ export const getInquiriesQueued = (serverVersion: string) => {
// this inquiry is added to the db by the subscriptions stream
// and will be removed by the queue stream
// RC 2.4.0
-export const takeInquiry = (inquiryId: string, serverVersion: string) => {
+export const takeInquiry = (inquiryId: string) => {
+ const serverVersion = reduxStore.getState().server.version;
if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.11.0')) {
return sdk.post('livechat/inquiries.take', { inquiryId });
}
diff --git a/app/ee/omnichannel/reducers/routingConfig.test.ts b/app/ee/omnichannel/reducers/routingConfig.test.ts
new file mode 100644
index 00000000000..236afa1eb46
--- /dev/null
+++ b/app/ee/omnichannel/reducers/routingConfig.test.ts
@@ -0,0 +1,18 @@
+import { inquiryReset } from '../actions/inquiry';
+import { routingConfigSuccess } from '../actions/routingConfig';
+import { selectServerRequest } from '../../../actions/server';
+import routingConfig, { initialState } from './routingConfig';
+
+describe('routingConfig reducer', () => {
+ it('stores the server value', () => {
+ expect(routingConfig(initialState, routingConfigSuccess(true))).toEqual({ returnQueue: true });
+ });
+
+ it('clears the value when a server selection starts', () => {
+ expect(routingConfig({ returnQueue: true }, selectServerRequest('https://new.example', '7.0.0'))).toEqual(initialState);
+ });
+
+ it('does not change when the inquiry queue resets', () => {
+ expect(routingConfig({ returnQueue: true }, inquiryReset())).toEqual({ returnQueue: true });
+ });
+});
diff --git a/app/ee/omnichannel/reducers/routingConfig.ts b/app/ee/omnichannel/reducers/routingConfig.ts
new file mode 100644
index 00000000000..5cd03a5bbc7
--- /dev/null
+++ b/app/ee/omnichannel/reducers/routingConfig.ts
@@ -0,0 +1,22 @@
+import { type AnyAction } from 'redux';
+import { ROUTING_CONFIG } from '../../../actions/actionsTypes';
+import { SERVER } from '../../../actions/actionsTypes';
+
+export interface IRoutingConfig {
+ returnQueue: boolean | null;
+}
+
+export const initialState: IRoutingConfig = {
+ returnQueue: null
+};
+
+export default function routingConfig(state = initialState, action: AnyAction): IRoutingConfig {
+ switch (action.type) {
+ case ROUTING_CONFIG.SUCCESS:
+ return { returnQueue: action.returnQueue };
+ case SERVER.SELECT_REQUEST:
+ return initialState;
+ default:
+ return state;
+ }
+}
diff --git a/app/ee/omnichannel/sagas/routingConfig.test.ts b/app/ee/omnichannel/sagas/routingConfig.test.ts
new file mode 100644
index 00000000000..02db5508026
--- /dev/null
+++ b/app/ee/omnichannel/sagas/routingConfig.test.ts
@@ -0,0 +1,39 @@
+jest.mock('../../../lib/services/restApi', () => ({ getRoutingConfig: jest.fn() }));
+
+import { getRoutingConfig } from '../../../lib/services/restApi';
+import { routingConfigRequest } from '../actions/routingConfig';
+import routingConfig from './routingConfig';
+import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../../lib/testUtils/sagaStore';
+
+const mockGetRoutingConfig = getRoutingConfig as jest.MockedFunction;
+
+afterEach(() => {
+ cancelSagaTasks();
+ jest.clearAllMocks();
+});
+
+it('loads once and retries after a failed request', async () => {
+ mockGetRoutingConfig.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ returnQueue: true } as never);
+ const { store } = createRecordingStore(routingConfig);
+
+ store.dispatch(routingConfigRequest());
+ await flushSagaMicrotasks();
+ expect(store.getState().routingConfig.returnQueue).toBeNull();
+
+ store.dispatch(routingConfigRequest());
+ await flushSagaMicrotasks();
+ expect(store.getState().routingConfig.returnQueue).toBe(true);
+ expect(mockGetRoutingConfig).toHaveBeenCalledTimes(2);
+});
+
+it('ignores repeated requests after loading', async () => {
+ mockGetRoutingConfig.mockResolvedValue({ returnQueue: false } as never);
+ const { store } = createRecordingStore(routingConfig);
+
+ store.dispatch(routingConfigRequest());
+ store.dispatch(routingConfigRequest());
+ await flushSagaMicrotasks();
+
+ expect(store.getState().routingConfig.returnQueue).toBe(false);
+ expect(mockGetRoutingConfig).toHaveBeenCalledTimes(1);
+});
diff --git a/app/ee/omnichannel/sagas/routingConfig.ts b/app/ee/omnichannel/sagas/routingConfig.ts
new file mode 100644
index 00000000000..1348cd131dd
--- /dev/null
+++ b/app/ee/omnichannel/sagas/routingConfig.ts
@@ -0,0 +1,23 @@
+import { put, select, takeLeading } from 'redux-saga/effects';
+
+import { ROUTING_CONFIG } from '../../../actions/actionsTypes';
+import { getRoutingConfig } from '../../../lib/services/restApi';
+import { routingConfigFailure, routingConfigSuccess } from '../actions/routingConfig';
+
+const handleRequest = function* handleRequest(): Generator {
+ const returnQueue = yield select(state => state.routingConfig.returnQueue);
+ if (returnQueue !== null) {
+ return;
+ }
+
+ try {
+ const routingConfig = yield getRoutingConfig();
+ yield put(routingConfigSuccess(routingConfig.returnQueue));
+ } catch (error) {
+ yield put(routingConfigFailure(error));
+ }
+};
+
+export default function* routingConfig() {
+ yield takeLeading(ROUTING_CONFIG.REQUEST, handleRequest);
+}
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 8f9606fe55d..7099e12f1fb 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -784,6 +784,7 @@
"room_changed_topic_to": "changed room topic to: {{topic}}",
"room_changed_type": "changed room to {{type}}",
"room_disallowed_reactions": "disallowed reactions",
+ "Room_failed_to_load": "This room could not be loaded. Check your connection and try again.",
"Room_Info": "Room info",
"Room_Info_Edit": "Room info edit",
"Room_name_changed_to": "changed room name to: {{name}}",
diff --git a/app/lib/methods/helpers/debounce.ts b/app/lib/methods/helpers/debounce.ts
index f792d965d34..1acf491e8e7 100644
--- a/app/lib/methods/helpers/debounce.ts
+++ b/app/lib/methods/helpers/debounce.ts
@@ -1,4 +1,4 @@
-import { useDebouncedCallback, type Options } from 'use-debounce';
+import { useDebouncedCallback, type DebouncedState, type Options } from 'use-debounce';
export function debounce(func: Function, wait?: number, immediate?: boolean) {
let timeout: ReturnType | null;
@@ -24,6 +24,6 @@ export function debounce(func: Function, wait?: number, immediate?: boolean) {
return _debounce;
}
-export function useDebounce(func: (...args: any) => any, wait?: number, options?: Options): (...args: any[]) => void {
+export function useDebounce any>(func: T, wait?: number, options?: Options): DebouncedState {
return useDebouncedCallback(func, wait || 1000, options);
}
diff --git a/app/lib/methods/helpers/goRoom.ts b/app/lib/methods/helpers/goRoom.ts
index f8c68db3233..3d7d808f526 100644
--- a/app/lib/methods/helpers/goRoom.ts
+++ b/app/lib/methods/helpers/goRoom.ts
@@ -68,7 +68,6 @@ const navigate = ({ item, isMasterDetail, ...props }: { item: TGoRoomItem; isMas
};
peekOrCreateRoomStore({
rid: routeParams.rid,
- t: routeParams.t,
initialRoom: {
rid: routeParams.rid,
t: routeParams.t as string,
diff --git a/app/reducers/index.js b/app/reducers/index.js
index 4258685f94f..ae7824146c8 100644
--- a/app/reducers/index.js
+++ b/app/reducers/index.js
@@ -1,6 +1,7 @@
import { combineReducers } from 'redux';
import inquiry from '../ee/omnichannel/reducers/inquiry';
+import routingConfig from '../ee/omnichannel/reducers/routingConfig';
import settings from './settings';
import login from './login';
import meteor from './connect';
@@ -45,6 +46,7 @@ export default combineReducers({
inviteLinks,
createDiscussion,
inquiry,
+ routingConfig,
enterpriseModules,
encryption,
permissions,
diff --git a/app/sagas/index.js b/app/sagas/index.js
index b60cc211837..d5b65d01919 100644
--- a/app/sagas/index.js
+++ b/app/sagas/index.js
@@ -1,6 +1,7 @@
import { all } from 'redux-saga/effects';
import inquiry from '../ee/omnichannel/sagas/inquiry';
+import routingConfig from '../ee/omnichannel/sagas/routingConfig';
import login from './login';
import rooms from './rooms';
import room from './room';
@@ -30,6 +31,7 @@ const root = function* root() {
inviteLinks(),
createDiscussion(),
inquiry(),
+ routingConfig(),
encryption(),
videoConf(),
troubleshootingNotification()
diff --git a/app/views/RoomView/List/__tests__/visibleSystemMessages.test.ts b/app/views/RoomView/List/__tests__/visibleSystemMessages.test.ts
new file mode 100644
index 00000000000..827968fe064
--- /dev/null
+++ b/app/views/RoomView/List/__tests__/visibleSystemMessages.test.ts
@@ -0,0 +1,53 @@
+import { buildVisibleSystemTypesClause, isHiddenSystemMessage, isLoaderMessage } from '../visibleSystemMessages';
+import { MESSAGE_TYPE_ANY_LOAD, MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';
+
+describe('buildVisibleSystemTypesClause', () => {
+ it('returns null when nothing is hidden', () => {
+ expect(buildVisibleSystemTypesClause([])).toBeNull();
+ });
+
+ it('keeps rows without a type, load rows, and rows of a type that is not hidden', () => {
+ const clause = buildVisibleSystemTypesClause(['uj', 'ul']);
+
+ expect(JSON.parse(JSON.stringify(clause))).toEqual({
+ type: 'or',
+ conditions: [
+ { type: 'where', left: 't', comparison: { operator: 'eq', right: { value: null } } },
+ { type: 'where', left: 't', comparison: { operator: 'oneOf', right: { values: [...MESSAGE_TYPE_ANY_LOAD] } } },
+ {
+ type: 'and',
+ conditions: [
+ { type: 'where', left: 't', comparison: { operator: 'notEq', right: { value: 'uj' } } },
+ { type: 'where', left: 't', comparison: { operator: 'notEq', right: { value: 'ul' } } }
+ ]
+ }
+ ]
+ });
+ });
+});
+
+describe('isLoaderMessage', () => {
+ it('matches every load type', () => {
+ expect(MESSAGE_TYPE_ANY_LOAD.every(t => isLoaderMessage({ t }))).toBe(true);
+ });
+
+ it('rejects plain and system messages', () => {
+ expect(isLoaderMessage({})).toBe(false);
+ expect(isLoaderMessage({ t: 'uj' })).toBe(false);
+ });
+});
+
+describe('isHiddenSystemMessage', () => {
+ it('hides a system message whose type is configured as hidden', () => {
+ expect(isHiddenSystemMessage({ t: 'uj' }, ['uj', 'ul'])).toBe(true);
+ });
+
+ it('keeps a system message of a type that is not hidden', () => {
+ expect(isHiddenSystemMessage({ t: 'au' }, ['uj'])).toBe(false);
+ });
+
+ it('keeps plain messages and loaders regardless of the hidden types', () => {
+ expect(isHiddenSystemMessage({}, ['uj'])).toBe(false);
+ expect(isHiddenSystemMessage({ t: MessageTypeLoad.MORE }, [MessageTypeLoad.MORE])).toBe(false);
+ });
+});
diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx
index f5c6a52c632..f376d738a3f 100644
--- a/app/views/RoomView/List/components/List.tsx
+++ b/app/views/RoomView/List/components/List.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
-import { StyleSheet, View } from 'react-native';
-import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated';
+import { FlatList, StyleSheet, View } from 'react-native';
+import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useIsScreenReaderEnabled } from '../../../../lib/hooks/useIsScreenReaderEnabled';
@@ -10,10 +10,13 @@ import { isExternalKeyboardConnected } from '../../../../lib/methods/helpers/ext
import { MESSAGE_COMPOSER_EXIT_FOCUS_NATIVE_ID } from '../../../../lib/constants/accessibility';
import InvertedScrollView from './InvertedScrollView';
import NavBottomFAB from './NavBottomFAB';
+import { type TAnyMessageModel } from '../../../../definitions';
import { type IListProps } from '../../definitions';
import { SCROLL_LIMIT } from '../constants';
import { useIsAutocompleteVisible } from '../../stores/ComposerStore';
+const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
+
const styles = StyleSheet.create({
list: {
flex: 1
@@ -26,12 +29,13 @@ const styles = StyleSheet.create({
const List = ({ flatListRef, jumpToBottom, isAnchored, ...props }: IListProps) => {
const [scrolledPastLimit, setScrolledPastLimit] = useState(false);
const isAutocompleteVisible = useIsAutocompleteVisible();
+ const wasScrolledPastLimit = useSharedValue(false);
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
- if (event.contentOffset.y > SCROLL_LIMIT) {
- scheduleOnRN(setScrolledPastLimit, true);
- } else {
- scheduleOnRN(setScrolledPastLimit, false);
+ const isPastLimit = event.contentOffset.y > SCROLL_LIMIT;
+ if (isPastLimit !== wasScrolledPastLimit.value) {
+ wasScrolledPastLimit.value = isPastLimit;
+ scheduleOnRN(setScrolledPastLimit, isPastLimit);
}
}
});
@@ -44,8 +48,7 @@ const List = ({ flatListRef, jumpToBottom, isAnchored, ...props }: IListProps) =
const renderScrollComponent = !isIOS && (isScreenReaderEnabled || isExternalKeyboardConnected());
return (
- {/* @ts-ignore */}
- Q.where('t', Q.notEq(h))));
-
- return Q.or(Q.where('t', null), Q.where('t', Q.oneOf([...MESSAGE_TYPE_ANY_LOAD])), notHidden);
-}
diff --git a/app/views/RoomView/List/hooks/useMessages.test.tsx b/app/views/RoomView/List/hooks/useMessages.test.tsx
index badc1457d12..2a57262b0b0 100644
--- a/app/views/RoomView/List/hooks/useMessages.test.tsx
+++ b/app/views/RoomView/List/hooks/useMessages.test.tsx
@@ -12,6 +12,7 @@ import { MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';
import { readThreads } from '../../../../lib/services/restApi';
import { mockedStore } from '../../../../reducers/mockedStore';
import { MAX_AUTO_LOADS, QUERY_SIZE } from '../constants';
+import { buildVisibleSystemTypesClause } from '../visibleSystemMessages';
import { useMessages } from './useMessages';
jest.mock('../../../../lib/database', () => ({
@@ -39,7 +40,7 @@ jest.mock('../../../../lib/methods/helpers', () => {
const actual = jest.requireActual('../../../../lib/methods/helpers');
return {
...actual,
- useDebounce: (fn: (...args: unknown[]) => unknown) => fn
+ useDebounce: (fn: (...args: unknown[]) => unknown) => Object.assign(fn, { cancel: jest.fn() })
};
});
@@ -194,12 +195,12 @@ describe('useMessages', () => {
expect(mockDbGet).not.toHaveBeenCalled();
});
- it('filters out system message types listed in hideSystemMessages', async () => {
- emittedRows = [msg({ id: '1', t: 'uj' }), msg({ id: '2', t: undefined }), msg({ id: '3', t: 'room_changed_topic' })];
- const { result } = renderUseMessages({ hideSystemMessages: ['uj'] });
+ it('asks the database to exclude system message types listed in hideSystemMessages', async () => {
+ renderUseMessages({ hideSystemMessages: ['uj'] });
await waitFor(() => {
- expect(result.current[0].map(m => m.id)).toEqual(['2', '3']);
+ expect(queryCalls.length).toBeGreaterThan(0);
});
+ expect(queryCalls[0]).toContainEqual(buildVisibleSystemTypesClause(['uj']));
});
it('returns visibleMessagesIds aligned with visible messages', async () => {
@@ -516,8 +517,6 @@ describe('useMessages', () => {
const firstTake = JSON.stringify(queryCalls[0].at(-1));
const secondTake = JSON.stringify(queryCalls[1].at(-1));
expect(firstTake).not.toEqual(secondTake);
- // Smoke check: the constant exists and is the expected step.
- expect(QUERY_SIZE).toBe(50);
});
it('calls readThreads when tmid is set', async () => {
@@ -656,8 +655,7 @@ describe('useMessages', () => {
const takeBeforeRaise = findTakeClause(queryCalls[queryCalls.length - 1])?.count;
expect(takeBeforeRaise).toBe(QUERY_SIZE);
- // The targeted read above the bound reveals the next batch plus a NEW Newer Loader at ts 1900.
- fetchRows = [msg({ id: 'm2', ts: at(1700) }), newerLoaderAt('loader-H2', 1900)];
+ fetchRows = [newerLoaderAt('loader-H2', 1900)];
// loadNextMessages REMOVED the boundary loader: re-emit WITHOUT it (still under the old bound).
emitRows([msg({ id: 'm1', ts: at(1000) })]);
@@ -682,8 +680,7 @@ describe('useMessages', () => {
expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500);
});
- // The targeted read reveals the next batch but NO new Newer Loader: the Gap to the Live Tail closed.
- fetchRows = [msg({ id: 'm2', ts: at(1700) }), msg({ id: 'm3', ts: at(1800) })];
+ fetchRows = [];
// loadNextMessages consumed the boundary loader: re-emit WITHOUT it.
emitRows([msg({ id: 'm1', ts: at(1000) })]);
@@ -711,7 +708,7 @@ describe('useMessages', () => {
const anchoredTake = findTakeClause(queryCalls[queryCalls.length - 1])?.count ?? 0;
// Gap closed (no Newer Loader above the bound), but 120 messages sit above it (the cached island).
- fetchRows = [msg({ id: 'm2', ts: at(1700) }), msg({ id: 'm3', ts: at(1800) })];
+ fetchRows = [];
fetchCountValue = 120;
// loadNextMessages consumed the boundary loader: re-emit without it.
@@ -738,8 +735,7 @@ describe('useMessages', () => {
expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500);
});
- // The targeted read still shows a Newer Loader above the bound — the Gap is NOT closed.
- fetchRows = [msg({ id: 'm2', ts: at(1700) }), newerLoaderAt('loader-H2', 1900)];
+ fetchRows = [newerLoaderAt('loader-H2', 1900)];
// Consume the boundary loader.
emitRows([msg({ id: 'm1', ts: at(1000) })]);
diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts
index ab866ef256b..dc7a558ed93 100644
--- a/app/views/RoomView/List/hooks/useMessages.ts
+++ b/app/views/RoomView/List/hooks/useMessages.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Q } from '@nozbe/watermelondb';
import { type Subscription } from 'rxjs';
import { useDispatch, useStore } from 'react-redux';
@@ -10,15 +10,13 @@ import { getThreadById } from '../../../../lib/database/services/Thread';
import { tsToMs } from '../../../../lib/dayjs';
import { compareServerVersion, useDebounce } from '../../../../lib/methods/helpers';
import { readThreads } from '../../../../lib/services/restApi';
-import { MESSAGE_TYPE_ANY_LOAD, MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';
import { MAX_AUTO_LOADS, QUERY_SIZE } from '../constants';
-import { buildVisibleSystemTypesClause } from './buildVisibleSystemTypesClause';
+import { buildVisibleSystemTypesClause, isHiddenSystemMessage, isLoaderMessage } from '../visibleSystemMessages';
import { roomHistoryRequest } from '../../../../actions/room';
import { isNewerLoader, raiseOrRelease } from '../../services/anchorResolver';
-import { type AnchorMessage } from '../../definitions';
+import { findNewerLoaderAbove } from '../../services/getLocalAnchor';
-const findFirstLoaderId = (messages: TAnyMessageModel[]): string | null =>
- messages.find(m => m.t && MESSAGE_TYPE_ANY_LOAD.includes(m.t as MessageTypeLoad))?.id ?? null;
+const findFirstLoaderId = (messages: TAnyMessageModel[]): string | null => messages.find(isLoaderMessage)?.id ?? null;
export const useMessages = ({
rid,
@@ -35,7 +33,7 @@ export const useMessages = ({
hideSystemMessages: string[];
t: RoomType;
}) => {
- const [rawMessages, setRawMessages] = useState([]);
+ const [messages, setMessages] = useState([]);
// Optional UPPER ts bound for the Message Window. null => Live Window (newest-first, follows the
// Live Tail). A finite number (ms since epoch) => Anchored Window pinned below the Live Tail.
const [highTs, setHighTsState] = useState(null);
@@ -66,6 +64,8 @@ export const useMessages = ({
}
}, 1000);
+ useEffect(() => readThread.cancel, [readThread]);
+
// Rejoin the Live Tail from an Anchored Window. Called on each emit while anchored: when the
// boundary Newer Loader (ts === highTs) flips present → absent, loadNextMessages has consumed it
// and written the next batch + a new loader ABOVE the current bound — which the bounded
@@ -74,7 +74,7 @@ export const useMessages = ({
const raiseOrReleaseAnchor = useCallback(
async (observed: TAnyMessageModel[], currentHighTs: number) => {
const wasPresent = boundaryLoaderPresent.current;
- const isPresent = (observed as unknown as AnchorMessage[]).some(m => isNewerLoader(m) && tsToMs(m.ts) === currentHighTs);
+ const isPresent = observed.some(m => isNewerLoader(m) && tsToMs(m.ts) === currentHighTs);
boundaryLoaderPresent.current = isPresent;
// Only a present → absent transition is a consume. Anything else (still present, or never
@@ -88,23 +88,13 @@ export const useMessages = ({
// invocation is stale and must not mutate count / highTs for the new window.
const sub = subscription.current;
- // Read the Newer Loader closest to the Live Tail directly (highest ts above the bound) so non-loader rows can't crowd the boundary loader out of the fetched set.
- const rows = (await database.active
- .get('messages')
- .query(
- Q.where('rid', rid),
- Q.where('t', MessageTypeLoad.NEXT_CHUNK),
- Q.where('ts', Q.gt(currentHighTs)),
- Q.sortBy('ts', Q.desc),
- Q.take(1)
- )
- .fetch()) as TAnyMessageModel[];
+ const loader = await findNewerLoaderAbove(rid, currentHighTs, 'closestToLiveTail');
if (subscription.current !== sub) {
return;
}
- const next = raiseOrRelease(rows as unknown as AnchorMessage[], currentHighTs);
+ const next = raiseOrRelease(loader ? [loader] : [], currentHighTs);
if (next === null) {
// Gap closed → release to a Live Window. First grow count by the number of messages now
@@ -149,7 +139,6 @@ export const useMessages = ({
// hideSystemMessages applied here so Q.take() counts only visible rows
const visibleSystemClause = buildVisibleSystemTypesClause(hideSystemMessages);
- let observable;
if (tmid) {
// Prefer threads table; fall back to messages while thread record isn't available yet
if (!thread.current || thread.current.collection.table !== 'threads') {
@@ -158,51 +147,37 @@ export const useMessages = ({
thread.current = await getMessageById(tmid);
}
}
- observable = db
- .get('thread_messages')
- .query(
- Q.where('rid', tmid),
- ...(visibleSystemClause ? [visibleSystemClause] : []),
- // Anchored Window upper bound (ts-only ordering: equal-ts rows can straddle the bound).
- ...(highTs != null ? [Q.where('ts', Q.lte(highTs))] : []),
- Q.sortBy('ts', Q.desc),
- Q.skip(0),
- Q.take(count.current)
- )
- .observe();
- } else {
- const whereClause: Q.Clause[] = [
- Q.where('rid', rid),
- ...(visibleSystemClause ? [visibleSystemClause] : []),
- // Anchored Window upper bound (ts-only ordering: equal-ts rows can straddle the bound).
- ...(highTs != null ? [Q.where('ts', Q.lte(highTs))] : []),
- Q.sortBy('ts', Q.desc),
- Q.skip(0),
- Q.take(count.current)
- ];
- if (!showMessageInMainThread) {
- whereClause.push(Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true))));
- }
- observable = db
- .get('messages')
- .query(...whereClause)
- .observe();
}
- subscription.current = observable.subscribe(result => {
- const newMessages: TAnyMessageModel[] = [...result];
+ const clauses: Q.Clause[] = [
+ Q.where('rid', tmid ?? rid),
+ ...(visibleSystemClause ? [visibleSystemClause] : []),
+ // Anchored Window upper bound (ts-only ordering: equal-ts rows can straddle the bound).
+ ...(highTs != null ? [Q.where('ts', Q.lte(highTs))] : []),
+ ...(!tmid && !showMessageInMainThread ? [Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true)))] : []),
+ Q.sortBy('ts', Q.desc),
+ Q.take(count.current)
+ ];
+
+ const observable = db
+ .get(tmid ? 'thread_messages' : 'messages')
+ .query(...clauses)
+ .observe();
- if (tmid && thread.current) {
- newMessages.push(thread.current);
- }
+ subscription.current = observable.subscribe(result => {
+ const visibleThreadParent =
+ tmid && thread.current && !isHiddenSystemMessage(thread.current, hideSystemMessages) ? thread.current : null;
+ const newMessages: TAnyMessageModel[] = visibleThreadParent ? [...result, visibleThreadParent] : result;
// Thread / local windows are never anchored, so rejoin only applies to the bounded main room.
if (!tmid && highTs != null) {
raiseOrReleaseAnchor(result as TAnyMessageModel[], highTs).catch(() => {});
}
- readThread();
- setRawMessages(newMessages);
+ if (tmid) {
+ readThread();
+ }
+ setMessages(newMessages);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- readThread is omitted intentionally: useDebouncedCallback stores func in a ref so changes propagate without recreating fetchMessages; hideSystemMessages must stay so the DB re-queries for proper pagination
}, [rid, tmid, showMessageInMainThread, hideSystemMessages, highTs, unsubscribe, raiseOrReleaseAnchor]);
@@ -224,30 +199,16 @@ export const useMessages = ({
return unsubscribe;
}, [fetchMessages, unsubscribe]);
- const visibleMessages = useMemo(
- () =>
- !hideSystemMessages || hideSystemMessages.length === 0
- ? rawMessages
- : rawMessages.filter(m => !m.t || !hideSystemMessages.includes(m.t)),
- [rawMessages, hideSystemMessages]
- );
-
- // Sync the IDs ref after render, outside the memo, to satisfy the react-hooks/refs rule
- // while still keeping the ref up to date before any paint (useLayoutEffect timing).
useLayoutEffect(() => {
- messagesIds.current = visibleMessages.map(m => m.id);
- }, [visibleMessages]);
+ messagesIds.current = messages.map(m => m.id);
+ }, [messages]);
useEffect(
() => {
- // Snapshot the currently-visible loader into lastDispatchedLoaderId so the
- // auto-dispatch effect treats it as already-seen when it re-fires after the rid
- // change — rawMessages may still reflect the previous room until the new
- // subscription emits, and we must not dispatch with a stale loader.
- lastDispatchedLoaderId.current = findFirstLoaderId(visibleMessages);
+ lastDispatchedLoaderId.current = findFirstLoaderId(messages);
autoLoadCount.current = 0;
},
- // eslint-disable-next-line react-hooks/exhaustive-deps -- visibleMessages intentionally omitted: stale read at rid-change is the desired behaviour
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- messages intentionally omitted: stale read at rid-change is the desired behaviour
[rid]
);
@@ -269,7 +230,7 @@ export const useMessages = ({
return;
}
- const loaderId = findFirstLoaderId(visibleMessages);
+ const loaderId = findFirstLoaderId(messages);
if (loaderId && loaderId !== lastDispatchedLoaderId.current) {
// Skip if a fetch for this loader is already in flight (push happens before the DB subscription emits)
@@ -280,7 +241,7 @@ export const useMessages = ({
autoLoadCount.current += 1;
dispatch(roomHistoryRequest({ rid, t, loaderId }));
}
- }, [highTs, serverVersion, rid, t, hideSystemMessages, visibleMessages, dispatch, store]);
+ }, [highTs, serverVersion, rid, t, hideSystemMessages, messages, dispatch, store]);
- return [visibleMessages, messagesIds, fetchMessages, { highTs, setHighTs }] as const;
+ return [messages, messagesIds, fetchMessages, { highTs, setHighTs }] as const;
};
diff --git a/app/views/RoomView/List/index.tsx b/app/views/RoomView/List/index.tsx
index 3033b897d39..639d1660258 100644
--- a/app/views/RoomView/List/index.tsx
+++ b/app/views/RoomView/List/index.tsx
@@ -1,4 +1,4 @@
-import { forwardRef, useImperativeHandle } from 'react';
+import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { useDebounce } from '../../../lib/methods/helpers';
import EmptyRoom from './components/EmptyRoom';
@@ -32,6 +32,8 @@ const ListContainer = forwardRef(
fetchMessages();
}, 300);
+ useEffect(() => onEndReached.cancel, [onEndReached]);
+
useImperativeHandle(ref, () => ({
jumpToMessage,
cancelJumpToMessage,
diff --git a/app/views/RoomView/List/visibleSystemMessages.ts b/app/views/RoomView/List/visibleSystemMessages.ts
new file mode 100644
index 00000000000..369d208837d
--- /dev/null
+++ b/app/views/RoomView/List/visibleSystemMessages.ts
@@ -0,0 +1,23 @@
+import { Q } from '@nozbe/watermelondb';
+
+import { MESSAGE_TYPE_ANY_LOAD, type MessageTypeLoad } from '../../../lib/constants/messageTypeLoad';
+
+export const isLoaderMessage = (message: { t?: string }): boolean =>
+ !!message.t && MESSAGE_TYPE_ANY_LOAD.includes(message.t as MessageTypeLoad);
+
+export const isHiddenSystemMessage = (message: { t?: string }, hideSystemMessages: string[]): boolean =>
+ !!message.t && !isLoaderMessage(message) && hideSystemMessages.includes(message.t);
+
+/**
+ * When the user hides system message types, applying `take(N)` before filtering would return
+ * mostly hidden rows. This clause matches `isHiddenSystemMessage` so `take` applies to visible rows only.
+ */
+export function buildVisibleSystemTypesClause(hideSystemMessages: string[]): Q.Or | null {
+ if (!hideSystemMessages.length) {
+ return null;
+ }
+
+ const notHidden = Q.and(...hideSystemMessages.map(h => Q.where('t', Q.notEq(h))));
+
+ return Q.or(Q.where('t', null), Q.where('t', Q.oneOf([...MESSAGE_TYPE_ANY_LOAD])), notHidden);
+}
diff --git a/app/views/RoomView/LoadMore/LoadMore.test.tsx b/app/views/RoomView/LoadMore/LoadMore.test.tsx
index 76534bdfe85..8b873fc724c 100644
--- a/app/views/RoomView/LoadMore/LoadMore.test.tsx
+++ b/app/views/RoomView/LoadMore/LoadMore.test.tsx
@@ -1,6 +1,51 @@
-import { generateSnapshots } from '../../../../.rnstorybook/generateSnapshots';
-import * as stories from './LoadMore.stories';
+import { ActivityIndicator } from 'react-native';
+import { Provider } from 'react-redux';
+import { render, screen, userEvent } from '@testing-library/react-native';
+
+import { createMockedStore } from '../../../reducers/mockedStore';
+import { MessageTypeLoad } from '../../../lib/constants/messageTypeLoad';
+import { type MessageType } from '../../../definitions';
+import LoadMore from '.';
jest.unmock('../../../lib/hooks/useResponsiveLayout/useResponsiveLayout');
-generateSnapshots(stories);
+const renderLoadMore = ({
+ type = MessageTypeLoad.MORE,
+ runOnRender = false
+}: { type?: MessageType; runOnRender?: boolean } = {}) => {
+ const store = createMockedStore();
+ render(
+
+
+
+ );
+ return store;
+};
+
+describe('LoadMore', () => {
+ test.each([
+ [MessageTypeLoad.MORE, 'Load more'],
+ [MessageTypeLoad.PREVIOUS_CHUNK, 'Load older'],
+ [MessageTypeLoad.NEXT_CHUNK, 'Load newer']
+ ])('renders the %s label', (type, label) => {
+ renderLoadMore({ type });
+
+ expect(screen.getByText(label)).toBeOnTheScreen();
+ });
+
+ test('shows the loading indicator instead of the label after being pressed', async () => {
+ renderLoadMore();
+
+ await userEvent.press(screen.getByText('Load more'));
+
+ expect(screen.queryByText('Load more')).not.toBeOnTheScreen();
+ expect(screen.UNSAFE_queryByType(ActivityIndicator)).not.toBeNull();
+ });
+
+ test('requests history on mount when runOnRender is set', () => {
+ const store = renderLoadMore({ runOnRender: true });
+
+ expect(store.getState().room.historyLoaders).toEqual(['loaderId']);
+ expect(screen.UNSAFE_queryByType(ActivityIndicator)).not.toBeNull();
+ });
+});
diff --git a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap
deleted file mode 100644
index 6ce1e1c0e98..00000000000
--- a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap
+++ /dev/null
@@ -1,8038 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Story Snapshots: Basic should match snapshot 1`] = `
-[
-
-
-
-
-
- Load more
-
-
-
- ,
-
-
-
-
-
-
-
- ,
-
-
-
-
-
- Load older
-
-
-
- ,
-
-
-
-
-
- Load newer
-
-
-
- ,
-]
-`;
-
-exports[`Story Snapshots: BlackTheme should match snapshot 1`] = `
-
-
-
-
-
-
-
- Load older
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- Hey!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Older message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Load newer
-
-
-
-
-
-
-
-
-
- Load more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is the third message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is the second message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- This is the first message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Story Snapshots: DarkTheme should match snapshot 1`] = `
-
-
-
-
-
-
-
- Load older
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- Hey!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Older message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Load newer
-
-
-
-
-
-
-
-
-
- Load more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is the third message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is the second message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- This is the first message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Story Snapshots: LightTheme should match snapshot 1`] = `
-
-
-
-
-
-
-
- Load older
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- Hey!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Older message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Load newer
-
-
-
-
-
-
-
-
-
- Load more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is the third message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is the second message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
- This is the first message
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/app/views/RoomView/RoomScreen.tsx b/app/views/RoomView/RoomScreen.tsx
new file mode 100644
index 00000000000..04202f2f0a9
--- /dev/null
+++ b/app/views/RoomView/RoomScreen.tsx
@@ -0,0 +1,131 @@
+import { useRef } from 'react';
+import { useStore } from 'zustand';
+
+import { useTheme } from '../../theme';
+import SafeAreaView from '../../containers/SafeAreaView';
+import { useMasterDetail } from '../../lib/hooks/useMasterDetail';
+import JoinCode from './components/JoinCode';
+import { type IJoinCode, type IRoomScreenProps } from './definitions';
+import { RoomProviders } from './components/RoomProviders';
+import { RoomAnnouncementBanner } from './components/RoomAnnouncementBanner';
+import { RoomFooter } from './components/RoomFooter/RoomFooter';
+import { RoomLoadFailed } from './components/RoomLoadFailed';
+import { RoomMessageActions } from './components/RoomMessageActions';
+import { RoomMessageList } from './components/RoomMessageList';
+import { RoomUploadProgress } from './components/RoomUploadProgress';
+import { RoomStoreContext } from './stores/RoomStoreContext';
+import { RoomScreenContext } from './stores/RoomScreenContext';
+import { useRoomMessaging } from './hooks/useRoomMessaging';
+import { useRoomSubscription } from './hooks/useRoomSubscription';
+import { useRoomAudioLifecycle } from './hooks/useRoomAudioLifecycle';
+import { useRoomRemoved } from './hooks/useRoomRemoved';
+import { useOmnichannelPermissions } from './hooks/useOmnichannelPermissions';
+import { useInAppFeedback } from './hooks/useInAppFeedback';
+
+const RoomScreen = ({ route, rid, t, tmid, roomStore }: IRoomScreenProps) => {
+ const { colors } = useTheme();
+ const isMasterDetail = useMasterDetail();
+
+ const room = useStore(roomStore, s => s.room);
+ const roomUpdate = useStore(roomStore, s => s.roomUpdate);
+ const roomUserId = useStore(roomStore, s => s.roomUserId);
+
+ const {
+ messageActionStore,
+ roomScreen,
+ messageComposerRef,
+ listContainerRef,
+ flatListRef,
+ messageActionsRef,
+ messageErrorActionsRef,
+ roomActions,
+ sendMessage,
+ jumpToMessage,
+ closeEmojiAndAction,
+ errorActionsShow,
+ onMessageLongPress,
+ onEditInit,
+ onEditCancel,
+ onEditRequest,
+ onQuoteInit,
+ onRemoveQuoteMessage,
+ onReactionInit,
+ onReactionPress,
+ onReplyInit,
+ setQuotesAndText,
+ getText
+ } = useRoomMessaging({
+ rid,
+ t,
+ tmid,
+ roomStore,
+ roomUserId,
+ quoteMessageId: route.params?.messageId
+ });
+ useRoomSubscription(rid, tmid);
+ useRoomAudioLifecycle(rid, tmid);
+ useRoomRemoved(rid, isMasterDetail);
+ useInAppFeedback();
+ useOmnichannelPermissions({ rid, t, roomStore });
+
+ const joinCodeRef = useRef(null);
+ const onJoin = () => {
+ roomStore.getState().join();
+ };
+
+ if (roomScreen.failed) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ {!tmid ? : null}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RoomScreen;
diff --git a/app/views/RoomView/__tests__/RoomGate.test.tsx b/app/views/RoomView/__tests__/RoomGate.test.tsx
new file mode 100644
index 00000000000..ef036141a32
--- /dev/null
+++ b/app/views/RoomView/__tests__/RoomGate.test.tsx
@@ -0,0 +1,128 @@
+import { View } from 'react-native';
+import { render, screen } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+import { createStore as createReduxStore } from 'redux';
+
+import RoomGate from '../index';
+import { type IRoomViewProps, type RoomState } from '../definitions';
+import { isInviteSubscription } from '../../../lib/methods/isInviteSubscription';
+import { useE2EEStatus } from '../hooks/useE2EEStatus';
+
+jest.mock('../RoomScreen', () => {
+ const { createElement } = require('react');
+ const { View: RNView } = require('react-native');
+ return { __esModule: true, default: () => createElement(RNView, { testID: 'room-screen' }) };
+});
+jest.mock('../components/InvitedRoomScreen', () => {
+ const { createElement } = require('react');
+ const { View: RNView } = require('react-native');
+ return { InvitedRoomScreen: () => createElement(RNView, { testID: 'invited-screen' }) };
+});
+jest.mock('../components/MissingRoomE2EEKey', () => {
+ const { createElement } = require('react');
+ const { View: RNView } = require('react-native');
+ return { MissingRoomE2EEKey: () => createElement(RNView, { testID: 'missing-key-screen' }) };
+});
+jest.mock('../components/EncryptedRoom', () => {
+ const { createElement } = require('react');
+ const { View: RNView } = require('react-native');
+ return { EncryptedRoom: () => createElement(RNView, { testID: 'encrypted-screen' }) };
+});
+jest.mock('../components/RoomRouteInvalid', () => {
+ const { createElement } = require('react');
+ const { View: RNView } = require('react-native');
+ return { RoomRouteInvalid: () => createElement(RNView, { testID: 'route-invalid-screen' }) };
+});
+jest.mock('../hooks/useHeader', () => ({ useHeader: jest.fn() }));
+jest.mock('../hooks/useE2EEStatus', () => ({
+ useE2EEStatus: jest.fn(() => ({ showMissingE2EEKey: false, showE2EEDisabledRoom: false }))
+}));
+jest.mock('../../../lib/methods/isInviteSubscription', () => ({ isInviteSubscription: jest.fn(() => false) }));
+jest.mock('../../../lib/methods/helpers', () => ({ getUidDirectMessage: jest.fn(), getRoomTitle: jest.fn(() => 'Room Title') }));
+
+const room: { current: RoomState['room'] } = { current: { rid: 'rid-1', t: 'c' } };
+
+jest.mock('../stores/RoomStore', () => {
+ const { createStore } = require('zustand');
+ const store = createStore(() => ({ room: {}, roomUpdate: {} }));
+ return {
+ useRoomStoreForScreen: () => {
+ store.setState({ room: room.current, roomUpdate: {} }, true);
+ return store;
+ }
+ };
+});
+
+const renderGate = (params: Record | null = { rid: 'rid-1', t: 'c' }) => {
+ const reduxStore = createReduxStore(() => ({ server: { version: '6.1.0' } }));
+ const props = {
+ route: { params: params ?? undefined },
+ navigation: { setOptions: jest.fn() }
+ } as unknown as IRoomViewProps;
+ return render(
+
+
+
+
+
+ );
+};
+
+describe('RoomGate', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ room.current = { rid: 'rid-1', t: 'c' };
+ jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: false });
+ jest.mocked(isInviteSubscription).mockReturnValue(false);
+ });
+
+ it('mounts the room screen when the room is not blocked', () => {
+ renderGate();
+
+ expect(screen.getByTestId('room-screen')).toBeOnTheScreen();
+ });
+
+ it('renders the invalid-route state instead of a room when the route has no identity', () => {
+ renderGate(null);
+
+ expect(screen.getByTestId('route-invalid-screen')).toBeOnTheScreen();
+ expect(screen.queryByTestId('room-screen')).toBeNull();
+ });
+
+ it('renders the invalid-route state when the route has a rid but no type', () => {
+ renderGate({ rid: 'rid-1' });
+
+ expect(screen.getByTestId('route-invalid-screen')).toBeOnTheScreen();
+ expect(screen.queryByTestId('room-screen')).toBeNull();
+ });
+
+ it('keeps the room screen unmounted while the room is an invite', () => {
+ room.current = { id: 'sub-1', rid: 'rid-1', t: 'c' } as RoomState['room'];
+ jest.mocked(isInviteSubscription).mockReturnValue(true);
+
+ renderGate();
+
+ expect(screen.getByTestId('invited-screen')).toBeOnTheScreen();
+ expect(screen.queryByTestId('room-screen')).toBeNull();
+ });
+
+ it('keeps the room screen unmounted while the E2EE key is missing', () => {
+ room.current = { rid: 'rid-1', t: 'c', encrypted: true } as RoomState['room'];
+ jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: true, showE2EEDisabledRoom: false });
+
+ renderGate();
+
+ expect(screen.getByTestId('missing-key-screen')).toBeOnTheScreen();
+ expect(screen.queryByTestId('room-screen')).toBeNull();
+ });
+
+ it('keeps the room screen unmounted while the session has E2EE disabled', () => {
+ room.current = { rid: 'rid-1', t: 'c', encrypted: true } as RoomState['room'];
+ jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: true });
+
+ renderGate();
+
+ expect(screen.getByTestId('encrypted-screen')).toBeOnTheScreen();
+ expect(screen.queryByTestId('room-screen')).toBeNull();
+ });
+});
diff --git a/app/views/RoomView/__tests__/sharedRoomStore.test.tsx b/app/views/RoomView/__tests__/sharedRoomStore.test.tsx
index 03cd126f4ed..9edf28d9d58 100644
--- a/app/views/RoomView/__tests__/sharedRoomStore.test.tsx
+++ b/app/views/RoomView/__tests__/sharedRoomStore.test.tsx
@@ -49,11 +49,19 @@ jest.mock('../List', () => {
__esModule: true,
default: () => {
const { lastSeen } = useRoomScreen();
+ const lastMessageFromAgent = require('../stores/RoomStoreContext').useRoomStore(
+ (s: { lastMessageFromAgent: boolean }) => s.lastMessageFromAgent
+ );
const tmid = useComposerTmid();
const onSendMessage = useOnSendMessage();
const screenName = tmid ? 'thread' : 'room';
return createElement(View, null, [
createElement(View, { key: 'seen', testID: `last-seen-${screenName}`, accessibilityLabel: String(lastSeen) }),
+ createElement(View, {
+ key: 'agent',
+ testID: `agent-message-${screenName}`,
+ accessibilityLabel: String(lastMessageFromAgent)
+ }),
createElement(Pressable, { key: 'send', testID: `send-${screenName}`, onPress: () => onSendMessage?.('hello') })
]);
}
@@ -154,13 +162,17 @@ const makeProps = (params: Record): IRoomViewProps =>
* Renders the room screen plus, optionally, a thread screen on the same rid — the pair a user
* gets after opening a thread. Re-render with `withThread: false` to unmount the thread screen.
*/
-const renderRoomAndThread = ({ startWithThread = true, rid = RID }: { startWithThread?: boolean; rid?: string } = {}) => {
+const renderRoomAndThread = ({
+ startWithThread = true,
+ rid = RID,
+ type = 'c'
+}: { startWithThread?: boolean; rid?: string; type?: string } = {}) => {
const reduxStore = makeReduxStore();
- const roomParams = { rid, t: 'c', name: 'general', joinCodeRequired: true };
+ const roomParams = { rid, t: type, name: 'general', joinCodeRequired: true };
const Screens = ({ withThread }: { withThread: boolean }) => (
-
{withThread ? : null}
+
);
const { rerender } = render();
@@ -196,7 +208,7 @@ describe('RoomView screens sharing one rid-keyed store', () => {
openThread();
- const [roomButton, threadButton] = screen.getAllByTestId('room-view-join-button');
+ const [threadButton, roomButton] = screen.getAllByTestId('room-view-join-button');
expect(roomButton).toBeEnabled();
expect(threadButton).toBeDisabled();
});
@@ -218,4 +230,13 @@ describe('RoomView screens sharing one rid-keyed store', () => {
expect(screen.getByTestId('last-seen-thread').props.accessibilityLabel).toBe('null');
expect(screen.getByTestId('last-seen-room').props.accessibilityLabel).toBe(String(ls));
});
+
+ it('shares the Livechat agent-authored flag when the thread screen mounts first', async () => {
+ const rid = 'rid-livechat-shared';
+ mockSubscriptionRows.current = [{ id: 'sub-1', rid, t: 'l', lastMessage: { u: { _id: 'agent-1' } } }];
+ renderRoomAndThread({ rid, type: 'l', startWithThread: true });
+
+ await waitFor(() => expect(screen.getByTestId('agent-message-room').props.accessibilityLabel).toBe('true'));
+ expect(screen.getByTestId('agent-message-thread').props.accessibilityLabel).toBe('true');
+ });
});
diff --git a/app/views/RoomView/components/EncryptedRoom.tsx b/app/views/RoomView/components/EncryptedRoom.tsx
index 53faa8ec157..be7b9c894e8 100644
--- a/app/views/RoomView/components/EncryptedRoom.tsx
+++ b/app/views/RoomView/components/EncryptedRoom.tsx
@@ -1,18 +1,15 @@
import { type ReactElement } from 'react';
-import { Linking, StyleSheet, Text, View } from 'react-native';
+import { Linking } from 'react-native';
import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
import { type ChatsStackParamList } from '../../../stacks/types';
import { useTheme } from '../../../theme';
-import { CustomIcon } from '../../../containers/CustomIcon';
import Button from '../../../containers/Button';
-import sharedStyles from '../../Styles';
import { useMasterDetail } from '../../../lib/hooks/useMasterDetail';
import { LEARN_MORE_E2EE_URL } from '../../../lib/encryption/constants';
import I18n from '../../../i18n';
import { type TNavigation } from '../../../stacks/stackType';
-
-const GAP = 32;
+import { RoomPlaceholder } from './RoomPlaceholder';
export const EncryptedRoom = ({
roomName,
@@ -22,7 +19,6 @@ export const EncryptedRoom = ({
navigation: NativeStackNavigationProp;
}): ReactElement => {
const { colors } = useTheme();
- const styles = useStyle();
const isMasterDetail = useMasterDetail();
const navigate = () => {
@@ -34,65 +30,18 @@ export const EncryptedRoom = ({
};
return (
-
-
-
-
-
-
- {I18n.t('encrypted_room_title', { room_name: roomName.slice(0, 30) })}
- {I18n.t('encrypted_room_description')}
-
-
-
-
+
+
+
);
};
-
-const useStyle = () => {
- const { colors } = useTheme();
- const styles = StyleSheet.create({
- root: {
- flex: 1,
- backgroundColor: colors.surfaceRoom
- },
- container: {
- flex: 1,
- marginHorizontal: 24,
- justifyContent: 'center'
- },
- textView: { alignItems: 'center' },
- icon: {
- width: 58,
- height: 58,
- borderRadius: 30,
- marginBottom: GAP,
- backgroundColor: colors.surfaceNeutral,
- alignItems: 'center',
- justifyContent: 'center'
- },
- title: {
- ...sharedStyles.textBold,
- fontSize: 24,
- lineHeight: 32,
- textAlign: 'center',
- color: colors.fontTitlesLabels,
- marginBottom: GAP
- },
- description: {
- ...sharedStyles.textRegular,
- fontSize: 16,
- lineHeight: 24,
- textAlign: 'center',
- color: colors.fontDefault,
- marginBottom: GAP
- }
- });
- return styles;
-};
diff --git a/app/views/RoomView/components/InvitedRoom.tsx b/app/views/RoomView/components/InvitedRoom.tsx
index 0eeb238eb75..60754c8db08 100644
--- a/app/views/RoomView/components/InvitedRoom.tsx
+++ b/app/views/RoomView/components/InvitedRoom.tsx
@@ -1,15 +1,11 @@
import { type ReactElement } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../../theme';
-import { CustomIcon } from '../../../containers/CustomIcon';
import Button from '../../../containers/Button';
-import sharedStyles from '../../Styles';
import I18n from '../../../i18n';
import type { IInviteSubscription } from '../../../definitions';
import Chip from '../../../containers/Chip';
-
-const GAP = 32;
+import { RoomPlaceholder } from './RoomPlaceholder';
type InvitedRoomProps = {
title: string;
@@ -22,79 +18,21 @@ type InvitedRoomProps = {
export const InvitedRoom = ({ title, description, inviter, loading, onAccept, onReject }: InvitedRoomProps): ReactElement => {
const { colors } = useTheme();
- const styles = useStyle();
return (
-
-
-
-
-
-
- {title}
- {description}
-
-
-
-
-
-
-
-
+ }>
+
+
+
);
};
-
-const useStyle = () => {
- const { colors } = useTheme();
- const styles = StyleSheet.create({
- root: {
- flex: 1,
- backgroundColor: colors.surfaceRoom
- },
- container: {
- flex: 1,
- marginHorizontal: 24,
- justifyContent: 'center'
- },
- textView: { alignItems: 'center' },
- icon: {
- width: 58,
- height: 58,
- borderRadius: 30,
- marginBottom: GAP,
- backgroundColor: colors.surfaceNeutral,
- alignItems: 'center',
- justifyContent: 'center'
- },
- title: {
- ...sharedStyles.textBold,
- fontSize: 24,
- lineHeight: 32,
- textAlign: 'center',
- color: colors.fontTitlesLabels,
- marginBottom: GAP
- },
- description: {
- ...sharedStyles.textRegular,
- fontSize: 16,
- lineHeight: 24,
- textAlign: 'center',
- color: colors.fontDefault
- },
- username: {
- ...sharedStyles.textRegular,
- fontSize: 16,
- lineHeight: 24,
- textAlign: 'center',
- color: colors.fontDefault,
- marginBottom: GAP
- }
- });
- return styles;
-};
diff --git a/app/views/RoomView/components/LeftButtons.tsx b/app/views/RoomView/components/LeftButtons.tsx
index 7ed820851d3..4ab339726ce 100644
--- a/app/views/RoomView/components/LeftButtons.tsx
+++ b/app/views/RoomView/components/LeftButtons.tsx
@@ -37,13 +37,12 @@ const LeftButtons = ({ rid, tmid }: ILeftButtonsProps): ReactElement | null => {
const unreadsCount = useUnreadsCount(rid);
if (!isMasterDetail || tmid) {
- let label = ' ';
- let labelLength = 1;
+ let label: string | undefined;
let marginLeft = 0;
let fontSize = 0;
if (unreadsCount) {
- label = unreadsCount > 99 ? '+99' : unreadsCount.toString() || ' ';
- labelLength = label.length ? label.length : 1;
+ label = unreadsCount > 99 ? '+99' : unreadsCount.toString();
+ const labelLength = label.length;
marginLeft = -4 * labelLength;
fontSize = labelLength > 1 ? 14 : 17;
}
diff --git a/app/views/RoomView/components/MessageRow.test.tsx b/app/views/RoomView/components/MessageRow.test.tsx
index ee19af235a6..04651da98d7 100644
--- a/app/views/RoomView/components/MessageRow.test.tsx
+++ b/app/views/RoomView/components/MessageRow.test.tsx
@@ -77,11 +77,12 @@ describe('MessageRow', () => {
const { emit } = setupObserve();
const sub: any = { id: 'sub-1', rid: 'rid-1', t: 'c', ignored: [] };
const item: any = { id: 'msg-1', ts: new Date('2024-01-01T10:00:00Z'), u: { _id: 'author-1' } };
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: sub });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: sub });
render(
-
+
diff --git a/app/views/RoomView/components/MissingRoomE2EEKey.tsx b/app/views/RoomView/components/MissingRoomE2EEKey.tsx
index b1793069ea8..510169576d1 100644
--- a/app/views/RoomView/components/MissingRoomE2EEKey.tsx
+++ b/app/views/RoomView/components/MissingRoomE2EEKey.tsx
@@ -1,77 +1,22 @@
import { type ReactElement } from 'react';
-import { Linking, StyleSheet, Text, View } from 'react-native';
+import { Linking } from 'react-native';
import { useTheme } from '../../../theme';
-import { CustomIcon } from '../../../containers/CustomIcon';
import Button from '../../../containers/Button';
-import sharedStyles from '../../Styles';
import I18n from '../../../i18n';
import { LEARN_MORE_E2EE_URL } from '../../../lib/encryption/constants';
-
-const GAP = 32;
+import { RoomPlaceholder } from './RoomPlaceholder';
export const MissingRoomE2EEKey = (): ReactElement => {
const { colors } = useTheme();
- const styles = useStyle();
return (
-
-
-
-
-
-
- {I18n.t('missing_room_e2ee_title')}
- {I18n.t('missing_room_e2ee_description')}
-
-
-
+
+
);
};
-
-const useStyle = () => {
- const { colors } = useTheme();
- const styles = StyleSheet.create({
- root: {
- flex: 1,
- backgroundColor: colors.surfaceRoom
- },
- container: {
- flex: 1,
- marginHorizontal: 24,
- justifyContent: 'center'
- },
- textView: { alignItems: 'center' },
- icon: {
- width: 58,
- height: 58,
- borderRadius: 30,
- marginBottom: GAP,
- backgroundColor: colors.surfaceNeutral,
- alignItems: 'center',
- justifyContent: 'center'
- },
- title: {
- ...sharedStyles.textBold,
- fontSize: 24,
- lineHeight: 32,
- textAlign: 'center',
- color: colors.fontTitlesLabels,
- marginBottom: GAP
- },
- description: {
- ...sharedStyles.textRegular,
- fontSize: 16,
- lineHeight: 24,
- textAlign: 'center',
- color: colors.fontDefault,
- marginBottom: GAP
- }
- });
- return styles;
-};
diff --git a/app/views/RoomView/components/RightButtons.test.tsx b/app/views/RoomView/components/RightButtons.test.tsx
index 716ac9b98af..69ce9742963 100644
--- a/app/views/RoomView/components/RightButtons.test.tsx
+++ b/app/views/RoomView/components/RightButtons.test.tsx
@@ -33,10 +33,10 @@ jest.mock('../../../lib/hooks/useAppSelector', () => ({
let mockRoomState = {
room: { rid: 'rid-1', t: 'c', name: 'general' },
- canForwardGuest: false,
- canReturnQueue: false,
- canPlaceLivechatOnHold: false
+ canForwardGuest: false
};
+jest.mock('../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false }));
+jest.mock('../hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false }));
jest.mock('../stores/RoomStore', () => ({
useRoomStoreByRid: (_rid: string | undefined, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState)
}));
@@ -79,9 +79,7 @@ describe('RightButtons', () => {
jest.clearAllMocks();
mockRoomState = {
room: { rid: 'rid-1', t: 'c', name: 'general' },
- canForwardGuest: false,
- canReturnQueue: false,
- canPlaceLivechatOnHold: false
+ canForwardGuest: false
};
mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false };
mockHeaderHooks = {
diff --git a/app/views/RoomView/components/RightButtons.tsx b/app/views/RoomView/components/RightButtons.tsx
index e795e0a4081..054a36413d9 100644
--- a/app/views/RoomView/components/RightButtons.tsx
+++ b/app/views/RoomView/components/RightButtons.tsx
@@ -13,6 +13,7 @@ import { closeLivechat as closeLivechatService } from '../../../lib/methods/help
import { events, logEvent } from '../../../lib/methods/helpers/log';
import getRoomAccessibilityLabel from '../../../lib/helpers/getRoomAccessibilityLabel';
import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { useCanReturnQueue } from '../../../ee/omnichannel/hooks/useCanReturnQueue';
import { useMasterDetail } from '../../../lib/hooks/useMasterDetail';
import { usePermissions } from '../../../lib/hooks/usePermissions';
import { getDepartmentInfo, getTagsList, onHoldLivechat, returnLivechat } from '../../../lib/services/restApi';
@@ -21,6 +22,7 @@ import { type TNavigation } from '../../../stacks/stackType';
import { type ChatsStackParamList } from '../../../stacks/types';
import { useTheme } from '../../../theme';
import { HeaderCallButton } from './HeaderCallButton';
+import { useCanPlaceLivechatOnHold } from '../hooks/useCanPlaceLivechatOnHold';
import { useE2EEStatus } from '../hooks/useE2EEStatus';
import { useSubscriptionUnreads } from '../hooks/useSubscriptionUnreads';
import { useThreadFollowing } from '../hooks/useThreadFollowing';
@@ -134,8 +136,8 @@ const RightButtons = ({ rid, tmid }: IRightButtonsProps): ReactElement | null =>
const room = useRoomStoreByRid(rid, s => s.room);
const canForwardGuest = useRoomStoreByRid(rid, s => s.canForwardGuest);
- const canReturnQueue = useRoomStoreByRid(rid, s => s.canReturnQueue);
- const canPlaceLivechatOnHold = useRoomStoreByRid(rid, s => s.canPlaceLivechatOnHold);
+ const canReturnQueue = useCanReturnQueue(room.t === 'l');
+ const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(rid);
const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(rid);
const hasE2EEWarning = !!('encrypted' in room && (showMissingE2EEKey || showE2EEDisabledRoom));
diff --git a/app/views/RoomView/components/RoomAnnouncementBanner.tsx b/app/views/RoomView/components/RoomAnnouncementBanner.tsx
new file mode 100644
index 00000000000..869037eb958
--- /dev/null
+++ b/app/views/RoomView/components/RoomAnnouncementBanner.tsx
@@ -0,0 +1,16 @@
+import I18n from '../../../i18n';
+import { useCloseBanner } from '../hooks/useCloseBanner';
+import { useRoomWithUpdate } from '../stores/RoomStoreContext';
+import Banner from './Banner';
+
+export const RoomAnnouncementBanner = () => {
+ const room = useRoomWithUpdate();
+ const closeBanner = useCloseBanner(room);
+
+ if (!('id' in room)) {
+ return null;
+ }
+ return (
+
+ );
+};
diff --git a/app/views/RoomView/components/RoomFooter/FooterAction.tsx b/app/views/RoomView/components/RoomFooter/FooterAction.tsx
new file mode 100644
index 00000000000..65d43d3a9fa
--- /dev/null
+++ b/app/views/RoomView/components/RoomFooter/FooterAction.tsx
@@ -0,0 +1,37 @@
+import { type ReactElement } from 'react';
+import { Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import Touch from '../../../../containers/Touch';
+import { useTheme } from '../../../../theme';
+import { useRoomScreen } from '../../stores/RoomScreenContext';
+import styles from './styles';
+
+export const FooterAction = ({
+ testID,
+ title,
+ buttonTestID,
+ buttonLabel,
+ onPress
+}: {
+ testID: string;
+ title: string;
+ buttonTestID: string;
+ buttonLabel: string;
+ onPress: () => void;
+}): ReactElement => {
+ const { colors } = useTheme();
+ const { bottom } = useSafeAreaInsets();
+ const { loading } = useRoomScreen();
+
+ return (
+
+ {title}
+
+
+ {buttonLabel}
+
+
+
+ );
+};
diff --git a/app/views/RoomView/components/RoomFooter/OnHold.tsx b/app/views/RoomView/components/RoomFooter/OnHold.tsx
index fb21ed6afaf..da9f938509c 100644
--- a/app/views/RoomView/components/RoomFooter/OnHold.tsx
+++ b/app/views/RoomView/components/RoomFooter/OnHold.tsx
@@ -1,27 +1,19 @@
-import { Text, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { type ReactElement } from 'react';
-import Touch from '../../../../containers/Touch';
import I18n from '../../../../i18n';
-import { useTheme } from '../../../../theme';
-import { useRoomScreen } from '../../stores/RoomScreenContext';
import { useRoomStore } from '../../stores/RoomStoreContext';
-import styles from './styles';
+import { FooterAction } from './FooterAction';
-export const OnHold = () => {
- const { colors } = useTheme();
- const { bottom } = useSafeAreaInsets();
- const { loading } = useRoomScreen();
+export const OnHold = (): ReactElement => {
const resumeRoom = useRoomStore(s => s.resumeRoom);
return (
-
- {I18n.t('Chat_is_on_hold')}
-
-
- {I18n.t('Resume')}
-
-
-
+
);
};
diff --git a/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx b/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx
index 6ce3c3e1ead..38fe71e58da 100644
--- a/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx
+++ b/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx
@@ -62,9 +62,8 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore =>
roomUserId: null,
canAutoTranslate: false,
canForwardGuest: false,
- canReturnQueue: false,
canViewCannedResponse: false,
- canPlaceLivechatOnHold: false,
+ lastMessageFromAgent: false,
init: jest.fn(() => Promise.resolve({ status: 'loaded', lastSeen: null })),
join: jest.fn(),
joinRoom: jest.fn(() => Promise.resolve()),
@@ -76,7 +75,8 @@ const renderFooter = (roomStore: RoomStore, reduxStore = makeReduxStore(), loadi
render(
-
+
diff --git a/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx b/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx
index 920e55b01dd..3ab011853cf 100644
--- a/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx
+++ b/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx
@@ -1,32 +1,24 @@
-import { Text, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { type ReactElement } from 'react';
-import Touch from '../../../../containers/Touch';
import I18n from '../../../../i18n';
-import { useTheme } from '../../../../theme';
import { type ITakeOrJoinProps } from '../../definitions';
-import { useRoomScreen } from '../../stores/RoomScreenContext';
import { useRoomStore, useRoomWithUpdate } from '../../stores/RoomStoreContext';
-import styles from './styles';
+import { FooterAction } from './FooterAction';
-export const TakeOrJoin = ({ joinCodeRef }: ITakeOrJoinProps) => {
- const { colors } = useTheme();
- const { bottom } = useSafeAreaInsets();
+export const TakeOrJoin = ({ joinCodeRef }: ITakeOrJoinProps): ReactElement => {
const room = useRoomWithUpdate();
- const { loading } = useRoomScreen();
const joinRoom = useRoomStore(s => s.joinRoom);
// The join-code modal lives on this screen, so the trigger is handed to joinRoom per call.
- const onPressJoin = () => joinRoom(() => joinCodeRef.current?.show());
+ const onPressJoin = (): Promise => joinRoom(() => joinCodeRef.current?.show());
return (
-
- {I18n.t('You_are_in_preview_mode')}
-
-
- {I18n.t(room.t === 'l' ? 'Take_it' : 'Join')}
-
-
-
+
);
};
diff --git a/app/views/RoomView/components/RoomLoadFailed.tsx b/app/views/RoomView/components/RoomLoadFailed.tsx
new file mode 100644
index 00000000000..ec146b53c41
--- /dev/null
+++ b/app/views/RoomView/components/RoomLoadFailed.tsx
@@ -0,0 +1,11 @@
+import { type ReactElement } from 'react';
+
+import Button from '../../../containers/Button';
+import I18n from '../../../i18n';
+import { RoomPlaceholder } from './RoomPlaceholder';
+
+export const RoomLoadFailed = ({ onRetry }: { onRetry: () => void }): ReactElement => (
+
+
+
+);
diff --git a/app/views/RoomView/components/RoomMessageActions.tsx b/app/views/RoomView/components/RoomMessageActions.tsx
index 5bf4f3633f8..a0fea302e3c 100644
--- a/app/views/RoomView/components/RoomMessageActions.tsx
+++ b/app/views/RoomView/components/RoomMessageActions.tsx
@@ -1,24 +1,13 @@
-import { type RefObject } from 'react';
-
-import MessageActions, { type IMessageActions, type IMessageActionsProps } from '../../../containers/MessageActions';
+import MessageActions, { type IMessageActions } from '../../../containers/MessageActions';
import MessageErrorActions, { type IMessageErrorActions } from '../../../containers/MessageErrorActions';
-import { type TRoomViewUser } from '../definitions';
+import { type IRoomMessageActionsProps } from '../definitions';
+import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { getUserSelector } from '../../../selectors/login';
import { useReadOnly } from '../hooks/useReadOnly';
import { useRoomStore } from '../stores/RoomStoreContext';
-type IRoomMessageActionsProps = Pick<
- IMessageActionsProps,
- 'editInit' | 'replyInit' | 'quoteInit' | 'reactionInit' | 'onReactionPress' | 'jumpToMessage'
-> & {
- tmid?: string;
- user: TRoomViewUser;
- messageActionsRef: RefObject;
- messageErrorActionsRef: RefObject;
-};
-
export const RoomMessageActions = ({
tmid,
- user,
messageActionsRef,
messageErrorActionsRef,
editInit,
@@ -29,6 +18,7 @@ export const RoomMessageActions = ({
jumpToMessage
}: IRoomMessageActionsProps) => {
const room = useRoomStore(s => s.room);
+ const user = useAppSelector(getUserSelector);
const readOnly = useReadOnly();
if (!('id' in room)) {
diff --git a/app/views/RoomView/components/RoomMessageList.tsx b/app/views/RoomView/components/RoomMessageList.tsx
new file mode 100644
index 00000000000..bbd553816c6
--- /dev/null
+++ b/app/views/RoomView/components/RoomMessageList.tsx
@@ -0,0 +1,79 @@
+import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { useSetting } from '../../../lib/hooks/useSetting';
+import { isRoomFederated } from '../../../lib/methods/isRoomFederated';
+import { getUserSelector } from '../../../selectors/login';
+import { type RoomType } from '../../../definitions';
+import { A11yGateProvider } from '../../../containers/message/stores/A11yGate';
+import { type IRoomMessageListProps, type IRoomViewState } from '../definitions';
+import { useRoomStore, useRoomWithUpdate } from '../stores/RoomStoreContext';
+import List from '../List';
+import { RoomMessageProvider } from './RoomMessageProvider';
+
+const EMPTY_HIDE_SYSTEM_MESSAGES: string[] = [];
+
+// FIXME: handle servers with version < 3.0.0
+const getHideSystemMessages = (room: IRoomViewState['room'], Hide_System_Messages?: string[]): string[] => {
+ const { sysMes } = room;
+ if (Array.isArray(sysMes)) {
+ return sysMes;
+ }
+ if (Array.isArray(Hide_System_Messages)) {
+ return Hide_System_Messages;
+ }
+ return EMPTY_HIDE_SYSTEM_MESSAGES;
+};
+
+export const RoomMessageList = ({
+ tmid,
+ listContainerRef,
+ flatListRef,
+ onLongPress,
+ roomActions,
+ jumpToMessage,
+ closeEmojiAndAction,
+ reactionInit,
+ errorActionsShow
+}: IRoomMessageListProps) => {
+ const room = useRoomWithUpdate();
+ const canAutoTranslate = useRoomStore(s => s.canAutoTranslate);
+ const showMessageInMainThread = useAppSelector(state => getUserSelector(state).showMessageInMainThread ?? false);
+ const serverVersion = useAppSelector(state => state.server.version);
+ const Message_GroupingPeriod = useSetting('Message_GroupingPeriod') as number;
+ const Message_Read_Receipt_Enabled = useSetting('Message_Read_Receipt_Enabled') as boolean;
+ const Hide_System_Messages = useSetting('Hide_System_Messages') as string[];
+
+ const subscribed = 'id' in room ? room : undefined;
+ const federated = !!subscribed && isRoomFederated(subscribed);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/app/views/RoomView/components/RoomMessageProvider.tsx b/app/views/RoomView/components/RoomMessageProvider.tsx
index 84fd30c9ba8..9c0f5c23937 100644
--- a/app/views/RoomView/components/RoomMessageProvider.tsx
+++ b/app/views/RoomView/components/RoomMessageProvider.tsx
@@ -1,12 +1,13 @@
import { type ReactElement, type ReactNode } from 'react';
import { MessageRoomProvider, type MessageRoomState } from '../../../containers/message/stores/MessageRoomStore';
+import { type IRoomActions } from '../definitions';
import { useRoomMessageHandlers } from '../hooks/useRoomMessageHandlers';
-type IRoomMessageProviderProps = Omit & { children: ReactNode };
+type IRoomMessageProviderProps = Omit & { children: ReactNode; roomActions: IRoomActions };
-export const RoomMessageProvider = ({ children, ...state }: IRoomMessageProviderProps): ReactElement => {
- const handlers = useRoomMessageHandlers(state.tmid);
+export const RoomMessageProvider = ({ children, roomActions, ...state }: IRoomMessageProviderProps): ReactElement => {
+ const handlers = useRoomMessageHandlers({ tmid: state.tmid, ...roomActions });
return (
diff --git a/app/views/RoomView/components/RoomPlaceholder.tsx b/app/views/RoomView/components/RoomPlaceholder.tsx
new file mode 100644
index 00000000000..5bae18dc07c
--- /dev/null
+++ b/app/views/RoomView/components/RoomPlaceholder.tsx
@@ -0,0 +1,79 @@
+import { type ReactElement, type ReactNode } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { CustomIcon, type TIconsName } from '../../../containers/CustomIcon';
+import { useTheme } from '../../../theme';
+import sharedStyles from '../../Styles';
+
+const GAP = 32;
+
+interface RoomPlaceholderProps {
+ icon: TIconsName;
+ title: string;
+ description: string;
+ detail?: ReactNode;
+ testID?: string;
+ children: ReactNode;
+}
+
+export const RoomPlaceholder = ({ icon, title, description, detail, testID, children }: RoomPlaceholderProps): ReactElement => {
+ const { colors } = useTheme();
+ const styles = useStyle(colors);
+
+ return (
+
+
+
+
+
+
+ {title}
+ {description}
+ {detail ? {detail} : null}
+
+ {children}
+
+
+ );
+};
+
+const useStyle = (colors: ReturnType['colors']) => {
+ const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ backgroundColor: colors.surfaceRoom
+ },
+ container: {
+ flex: 1,
+ marginHorizontal: 24,
+ justifyContent: 'center'
+ },
+ textView: { alignItems: 'center' },
+ icon: {
+ width: 58,
+ height: 58,
+ borderRadius: 30,
+ marginBottom: GAP,
+ backgroundColor: colors.surfaceNeutral,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ title: {
+ ...sharedStyles.textBold,
+ fontSize: 24,
+ lineHeight: 32,
+ textAlign: 'center',
+ color: colors.fontTitlesLabels,
+ marginBottom: GAP
+ },
+ description: {
+ ...sharedStyles.textRegular,
+ fontSize: 16,
+ lineHeight: 24,
+ textAlign: 'center',
+ color: colors.fontDefault
+ },
+ gapBottom: { marginBottom: GAP }
+ });
+ return styles;
+};
diff --git a/app/views/RoomView/components/RoomRouteInvalid.tsx b/app/views/RoomView/components/RoomRouteInvalid.tsx
new file mode 100644
index 00000000000..cea11db4d92
--- /dev/null
+++ b/app/views/RoomView/components/RoomRouteInvalid.tsx
@@ -0,0 +1,12 @@
+import { type ReactElement } from 'react';
+
+import Button from '../../../containers/Button';
+import I18n from '../../../i18n';
+import { type IRoomViewProps } from '../definitions';
+import { RoomPlaceholder } from './RoomPlaceholder';
+
+export const RoomRouteInvalid = ({ navigation }: Pick): ReactElement => (
+
+
+);
diff --git a/app/views/RoomView/components/RoomUploadProgress.tsx b/app/views/RoomView/components/RoomUploadProgress.tsx
new file mode 100644
index 00000000000..3f7619b7226
--- /dev/null
+++ b/app/views/RoomView/components/RoomUploadProgress.tsx
@@ -0,0 +1,15 @@
+import { useWindowDimensions } from 'react-native';
+
+import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { getUserSelector } from '../../../selectors/login';
+import { useRoomStore } from '../stores/RoomStoreContext';
+import UploadProgress from './UploadProgress';
+
+export const RoomUploadProgress = () => {
+ const rid = useRoomStore(s => s.room.rid);
+ const user = useAppSelector(getUserSelector);
+ const baseUrl = useAppSelector(state => state.server.server);
+ const { width } = useWindowDimensions();
+
+ return ;
+};
diff --git a/app/views/RoomView/constants.test.ts b/app/views/RoomView/constants.test.ts
index 0d1c46d4654..5eb6c775a11 100644
--- a/app/views/RoomView/constants.test.ts
+++ b/app/views/RoomView/constants.test.ts
@@ -4,7 +4,7 @@ describe('roomAttrsUpdate invariant', () => {
// useReadOnly / useE2EEStatus derive synchronously from the observed room, so they only stay
// reactive while these columns are observed. Dropping one of these columns from roomAttrsUpdate
// silently breaks that reactivity at runtime with no other failing signal — this test is the
- // only guard. See docs/ARCHITECTURE.md.
+ // only guard.
it.each(['roles', 'encrypted', 'E2EKey'])('keeps %s so read-time derivations stay reactive', key => {
expect(roomAttrsUpdate).toContain(key);
});
diff --git a/app/views/RoomView/constants.ts b/app/views/RoomView/constants.ts
index c772e92dbe4..7136a340bfd 100644
--- a/app/views/RoomView/constants.ts
+++ b/app/views/RoomView/constants.ts
@@ -24,7 +24,6 @@ export const roomAttrsUpdate = [
'teamMain',
'teamId',
'status',
- 'lastMessage',
'onHold',
't',
'autoTranslate',
@@ -59,7 +58,6 @@ export const roomAttrsUpdateColumns: Record<(typeof roomAttrsUpdate)[number], st
teamMain: 'team_main',
teamId: 'team_id',
status: 'status',
- lastMessage: 'last_message',
onHold: 'on_hold',
t: 't',
autoTranslate: 'auto_translate',
diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts
index 4c5a3fd0189..ac2e94e6ee6 100644
--- a/app/views/RoomView/definitions.ts
+++ b/app/views/RoomView/definitions.ts
@@ -8,7 +8,6 @@ import {
type IBaseScreen,
type IEmoji,
type ILastMessage,
- type ILoggedUser,
type IMessage,
type IMessageEditAttachment,
type IVisitor,
@@ -18,13 +17,30 @@ import {
} from '../../definitions';
import { type TActionSheetOptions } from '../../containers/ActionSheet';
import { type IMessageComposerRef } from '../../containers/MessageComposer/interfaces';
-import { type IMessageActions } from '../../containers/MessageActions';
+import { type IMessageActions, type IMessageActionsProps } from '../../containers/MessageActions';
import { type IMessageErrorActions } from '../../containers/MessageErrorActions';
import { type TMessageActionStore } from '../../containers/message/stores/MessageActionStore';
+import { type MessageRoomState } from '../../containers/message/stores/MessageRoomStore';
export type IRoomViewProps = Pick, 'navigation' | 'route'>;
-export type TRoomViewUser = Pick;
+// The route parsed once at mount. A room screen's identity never legitimately changes, and a route
+// without one renders a failure state instead of a room.
+export interface IRoomScreenInput {
+ rid: string;
+ t: string;
+ tmid?: string;
+ /** Thread name on a thread; the observed subscription row never carries it. */
+ name?: string;
+ initialRoom: IRoomViewState['room'];
+ roomUserId?: string | null;
+}
+
+export type TRoomRouteParse = { status: 'valid'; input: IRoomScreenInput } | { status: 'invalid' };
+
+export interface IRoomScreenProps extends Pick, Pick {
+ roomStore: RoomStore;
+}
export interface IRoomFooterProps {
messageComposerRef: RefObject;
@@ -57,9 +73,7 @@ export interface IRoomViewState {
sysMes?: boolean;
onHold?: boolean;
};
- roomUpdate: {
- [K in TRoomUpdate]?: any;
- };
+ roomUpdate: Partial>;
member: any;
lastSeen: Date | null;
}
@@ -97,9 +111,10 @@ export interface IJumpToMessageArgs {
tmid?: string;
t?: string;
listContainerRef: RefObject;
- navToRoom: (message: TGetMessageInfoResult) => void;
- navToThread: (message: TGetMessageInfoResult) => void;
+ navToRoom: (message: TGetMessageInfoResult) => void | Promise;
+ navToThread: (message: TGetMessageInfoResult) => void | Promise;
cancel: () => void;
+ isCancelled: () => boolean;
}
export type TListRef = RefObject | null>;
@@ -135,9 +150,17 @@ export interface IListContainerProps {
serverVersion: string | null;
}
+export interface IRoomActions {
+ onThreadPress: (item: TAnyMessageModel) => void;
+ onReactionPress: (emoji: IEmoji, messageId: string) => Promise;
+ sendMessage: (message?: string, tshow?: boolean) => void;
+}
+
// The screen's own state, carried by RoomScreenContext — see that module for why it is per-screen.
export interface IRoomScreenContextValue {
loading: boolean;
+ failed: boolean;
+ retry: () => void;
lastSeen: IRoomViewState['lastSeen'];
clearLastSeen: () => void;
}
@@ -168,9 +191,8 @@ export interface RoomState {
roomUserId?: string | null;
canAutoTranslate: boolean;
canForwardGuest: boolean;
- canReturnQueue: boolean;
canViewCannedResponse: boolean;
- canPlaceLivechatOnHold: boolean;
+ lastMessageFromAgent: boolean;
// Resolves with the run's outcome; only `loaded` carries the screen's unread divider anchor.
init: (params?: IRoomStoreInitParams) => Promise;
join: () => void;
@@ -179,7 +201,6 @@ export interface RoomState {
}
export interface IJoinRoomContext {
- serverVersion?: string | null;
requestJoinCode?: () => void;
onJoin: () => void;
}
@@ -188,7 +209,6 @@ export type RoomStore = StoreApi;
export interface IGetOrCreateRoomStoreParams {
rid?: string;
- t?: string;
initialRoom: IRoomViewState['room'];
roomUserId?: string | null;
}
@@ -208,7 +228,7 @@ export type TGetMessageInfoResult = {
export interface AnchorMessage {
id: string;
t?: string | null;
- ts: Date | number;
+ ts: Date | number | string;
}
export interface IJumpTarget {
@@ -247,7 +267,6 @@ export interface IUseMessageActionsParams {
hideActionSheet: () => void;
rid?: string;
tmid?: string;
- roomUserId?: string | null;
onThreadPress: (item: TAnyMessageModel) => void;
messageComposerRef: RefObject;
messageActionsRef: RefObject;
@@ -275,6 +294,61 @@ export interface IUseMessageActionsResult {
getText: () => string | undefined;
}
+export interface IRoomMessageListProps extends Pick<
+ MessageRoomState,
+ 'jumpToMessage' | 'closeEmojiAndAction' | 'reactionInit' | 'errorActionsShow'
+> {
+ tmid?: string;
+ listContainerRef: RefObject;
+ flatListRef: TListRef;
+ onLongPress: IListContainerProps['onLongPress'];
+ roomActions: IRoomActions;
+}
+
+export type IRoomMessageActionsProps = Pick<
+ IMessageActionsProps,
+ 'editInit' | 'replyInit' | 'quoteInit' | 'reactionInit' | 'onReactionPress' | 'jumpToMessage'
+> & {
+ tmid?: string;
+ messageActionsRef: RefObject;
+ messageErrorActionsRef: RefObject;
+};
+
+export interface IUseRoomMessagingParams {
+ rid?: string;
+ t?: string;
+ tmid?: string;
+ roomStore: RoomStore;
+ roomUserId?: string | null;
+ quoteMessageId?: string;
+}
+
+export interface IUseRoomMessagingResult {
+ messageActionStore: TMessageActionStore;
+ roomScreen: IRoomScreenContextValue;
+ messageComposerRef: RefObject;
+ listContainerRef: RefObject;
+ flatListRef: TListRef;
+ messageActionsRef: RefObject;
+ messageErrorActionsRef: RefObject;
+ roomActions: IRoomActions;
+ sendMessage: TComposerExternalState['onSendMessage'];
+ jumpToMessage: IMessageActionsProps['jumpToMessage'];
+ closeEmojiAndAction: IRoomMessageListProps['closeEmojiAndAction'];
+ errorActionsShow: IRoomMessageListProps['errorActionsShow'];
+ onMessageLongPress: IListContainerProps['onLongPress'];
+ onEditInit: IMessageActionsProps['editInit'];
+ onEditCancel: TComposerExternalState['editCancel'];
+ onEditRequest: TComposerExternalState['editRequest'];
+ onQuoteInit: IMessageActionsProps['quoteInit'];
+ onRemoveQuoteMessage: TComposerExternalState['onRemoveQuoteMessage'];
+ onReactionInit: IMessageActionsProps['reactionInit'];
+ onReactionPress: IMessageActionsProps['onReactionPress'];
+ onReplyInit: IMessageActionsProps['replyInit'];
+ setQuotesAndText: TComposerExternalState['setQuotesAndText'];
+ getText: TComposerExternalState['getText'];
+}
+
export interface IUseSubscriptionUnreadsResult {
tunread: string[];
tunreadUser: string[];
@@ -288,8 +362,8 @@ export interface IUseJumpToMessageParams {
tmid?: string;
t?: string;
listContainerRef: RefObject;
- navToRoom: (message: TGetMessageInfoResult) => void;
- navToThread: (message: TGetMessageInfoResult | { tmid: string }) => void;
+ navToRoom: (message: TGetMessageInfoResult) => void | Promise;
+ navToThread: (message: TGetMessageInfoResult | { tmid: string }) => void | Promise;
}
export interface IUseJumpToMessageResult {
@@ -316,9 +390,5 @@ export interface IUseRoomNavigationResult {
export interface IUseOmnichannelPermissionsParams {
rid?: string;
t?: string;
- room: IRoomViewState['room'];
- roomUpdate: IRoomViewState['roomUpdate'];
- joined: boolean;
- livechatAllowManualOnHold?: boolean;
roomStore: RoomStore;
}
diff --git a/app/views/RoomView/hooks/__tests__/useCanPlaceLivechatOnHold.test.ts b/app/views/RoomView/hooks/__tests__/useCanPlaceLivechatOnHold.test.ts
new file mode 100644
index 00000000000..a5aa45ca359
--- /dev/null
+++ b/app/views/RoomView/hooks/__tests__/useCanPlaceLivechatOnHold.test.ts
@@ -0,0 +1,46 @@
+import { renderHook } from '@testing-library/react-native';
+
+import { useCanPlaceLivechatOnHold } from '../useCanPlaceLivechatOnHold';
+
+let mockSetting = true;
+jest.mock('../../../../lib/hooks/useSetting', () => ({ useSetting: () => mockSetting }));
+
+let mockState = {
+ room: { rid: 'rid-1', t: 'l' },
+ roomUpdate: { onHold: false } as { onHold?: boolean },
+ lastMessageFromAgent: true
+};
+jest.mock('../../stores/RoomStore', () => ({
+ useRoomStoreByRid: (_rid: string | undefined, selector: (state: typeof mockState) => unknown) => selector(mockState)
+}));
+
+describe('useCanPlaceLivechatOnHold', () => {
+ beforeEach(() => {
+ mockSetting = true;
+ mockState = { room: { rid: 'rid-1', t: 'l' }, roomUpdate: { onHold: false }, lastMessageFromAgent: true };
+ });
+
+ it('allows on-hold when the setting is on, the agent spoke last and the room is not on hold', () => {
+ expect(renderHook(() => useCanPlaceLivechatOnHold('rid-1')).result.current).toBe(true);
+ });
+
+ it('denies on-hold when the room is already on hold', () => {
+ mockState = { ...mockState, roomUpdate: { onHold: true } };
+ expect(renderHook(() => useCanPlaceLivechatOnHold('rid-1')).result.current).toBe(false);
+ });
+
+ it('denies on-hold when the visitor spoke last', () => {
+ mockState = { ...mockState, lastMessageFromAgent: false };
+ expect(renderHook(() => useCanPlaceLivechatOnHold('rid-1')).result.current).toBe(false);
+ });
+
+ it('denies on-hold when the setting is off', () => {
+ mockSetting = false;
+ expect(renderHook(() => useCanPlaceLivechatOnHold('rid-1')).result.current).toBe(false);
+ });
+
+ it('denies on-hold outside livechat rooms', () => {
+ mockState = { ...mockState, room: { rid: 'rid-1', t: 'c' } };
+ expect(renderHook(() => useCanPlaceLivechatOnHold('rid-1')).result.current).toBe(false);
+ });
+});
diff --git a/app/views/RoomView/hooks/__tests__/useGoRoomActionsView.test.ts b/app/views/RoomView/hooks/__tests__/useGoRoomActionsView.test.ts
index 6ef3cfd292b..d39c260a0c6 100644
--- a/app/views/RoomView/hooks/__tests__/useGoRoomActionsView.test.ts
+++ b/app/views/RoomView/hooks/__tests__/useGoRoomActionsView.test.ts
@@ -23,10 +23,10 @@ const mockState = {
member: { _id: 'm1' },
joined: true,
canForwardGuest: true,
- canReturnQueue: true,
- canViewCannedResponse: true,
- canPlaceLivechatOnHold: true
+ canViewCannedResponse: true
};
+jest.mock('../../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => true }));
+jest.mock('../useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => true }));
jest.mock('../../stores/RoomStore', () => ({
useRoomStoreByRid: (_rid: string | undefined, selector: (state: typeof mockState) => unknown) => selector(mockState)
diff --git a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx b/app/views/RoomView/hooks/__tests__/useHeader.test.tsx
index f9e89fb2254..594a62c37b4 100644
--- a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx
+++ b/app/views/RoomView/hooks/__tests__/useHeader.test.tsx
@@ -39,9 +39,8 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore =>
roomUserId: null,
canAutoTranslate: false,
canForwardGuest: false,
- canReturnQueue: false,
canViewCannedResponse: false,
- canPlaceLivechatOnHold: false,
+ lastMessageFromAgent: false,
init: jest.fn(),
join: jest.fn(),
joinRoom: jest.fn(() => Promise.resolve()),
@@ -78,18 +77,6 @@ describe('useHeader', () => {
expect(mockSetOptions.mock.calls[2][0]).toHaveProperty('headerTitle');
});
- it('does not re-fire the title effect when only lastMessage changes', () => {
- mockTestStore = makeRoomStore({ roomUpdate: { topic: 'same', lastMessage: { msg: 'old' } } });
-
- renderHook(() => useHeader({ rid: 'rid-1', tmid: undefined, name: 'general' }));
- expect(mockSetOptions).toHaveBeenCalledTimes(2);
-
- act(() => {
- mockTestStore.setState({ roomUpdate: { topic: 'same', lastMessage: { msg: 'new' } } });
- });
- expect(mockSetOptions).toHaveBeenCalledTimes(2);
- });
-
it('keeps the thread title from the passed name when the observed room name changes', () => {
renderHook(() => useHeader({ rid: 'rid-1', tmid: 'tmid-1', name: 'Thread name' }));
diff --git a/app/views/RoomView/hooks/__tests__/useJumpToMessage.test.tsx b/app/views/RoomView/hooks/__tests__/useJumpToMessage.test.tsx
index 2d432345891..3f862248b39 100644
--- a/app/views/RoomView/hooks/__tests__/useJumpToMessage.test.tsx
+++ b/app/views/RoomView/hooks/__tests__/useJumpToMessage.test.tsx
@@ -186,6 +186,143 @@ describe('useJumpToMessage', () => {
expect(listContainerRef.current.jumpToMessage).not.toHaveBeenCalled();
});
+ it('navigates to the main room when jumping from a thread to a different thread parent with replies', async () => {
+ const listContainerRef = createListRef();
+ mockGetMessageInfo.mockResolvedValue({ id: 'other-parent', rid: RID, ts: 100, replies: ['u1'] });
+ const { result, navToRoom } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1', t: 'thread' });
+
+ await act(async () => {
+ await result.current.jumpToMessage('other-parent');
+ });
+
+ expect(navToRoom).toHaveBeenCalledWith({ id: 'other-parent', rid: RID, ts: 100, replies: ['u1'] });
+ expect(listContainerRef.current.jumpToMessage).not.toHaveBeenCalled();
+ });
+
+ it('scrolls in place when jumping to the parent of the thread being viewed', async () => {
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ mockGetMessageInfo.mockResolvedValue({ id: 'tmid-1', rid: RID, ts: 100, replies: ['u1'] });
+ const { result, navToRoom } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1', t: 'thread' });
+
+ await act(async () => {
+ await result.current.jumpToMessage('tmid-1');
+ });
+
+ expect(navToRoom).not.toHaveBeenCalled();
+ expect(listContainerRef.current.jumpToMessage).toHaveBeenCalledWith('tmid-1', null);
+ });
+
+ it('handles a rejected room navigation as an error instead of leaking it', async () => {
+ const listContainerRef = createListRef();
+ const error = new Error('room lookup failed');
+ mockGetMessageInfo.mockResolvedValue({ id: 'm2', rid: 'rid-2', ts: 100 });
+ const { result, navToRoom } = renderJumpToMessage(listContainerRef);
+ navToRoom.mockRejectedValue(error);
+
+ await act(async () => {
+ await result.current.jumpToMessage('m2');
+ });
+
+ expect(mockLog).toHaveBeenCalledWith(error);
+ expect(listContainerRef.current.cancelJumpToMessage).toHaveBeenCalledTimes(1);
+ expect(mockSendLoadingEvent).toHaveBeenLastCalledWith({ visible: false });
+ });
+
+ it('handles a rejected thread navigation as an error instead of leaking it', async () => {
+ const listContainerRef = createListRef();
+ const error = new Error('thread name failed');
+ mockGetMessageInfo.mockResolvedValue({ id: 'm3', rid: RID, tmid: 'other-tmid', ts: 100 });
+ const { result, navToThread } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1' });
+ navToThread.mockRejectedValue(error);
+
+ await act(async () => {
+ await result.current.jumpToMessage('m3');
+ });
+
+ expect(mockLog).toHaveBeenCalledWith(error);
+ expect(listContainerRef.current.cancelJumpToMessage).toHaveBeenCalledTimes(1);
+ expect(mockSendLoadingEvent).toHaveBeenLastCalledWith({ visible: false });
+ });
+
+ it('drops the jump when cancelled before the message lookup resolves', async () => {
+ const listContainerRef = createListRef();
+ let resolveMessage: (value: unknown) => void = () => {};
+ mockGetMessageInfo.mockReturnValue(new Promise(res => (resolveMessage = res)));
+ const { result, navToRoom } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1', t: 'thread' });
+
+ await act(async () => {
+ const jump = result.current.jumpToMessage('m5');
+ result.current.cancelJumpToMessage();
+ resolveMessage({ id: 'm5', rid: RID, ts: 100 });
+ await jump;
+ });
+
+ expect(navToRoom).not.toHaveBeenCalled();
+ expect(listContainerRef.current.jumpToMessage).not.toHaveBeenCalled();
+ });
+
+ it('drops the jump when cancelled before the anchor resolves', async () => {
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ mockGetMessageInfo.mockResolvedValue({ id: 'm6', rid: RID, ts: 100 });
+ let resolveAnchor: (value: unknown) => void = () => {};
+ mockResolveJumpAnchor.mockReturnValue(new Promise(res => (resolveAnchor = res)));
+ const { result } = renderJumpToMessage(listContainerRef);
+
+ await act(async () => {
+ const jump = result.current.jumpToMessage('m6');
+ await Promise.resolve();
+ result.current.cancelJumpToMessage();
+ resolveAnchor(null);
+ await jump;
+ });
+
+ expect(listContainerRef.current.jumpToMessage).not.toHaveBeenCalled();
+ });
+
+ it('drops an earlier jump that resolves after a newer one started', async () => {
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ let resolveFirst: (value: unknown) => void = () => {};
+ mockGetMessageInfo.mockReturnValueOnce(new Promise(res => (resolveFirst = res)));
+ mockGetMessageInfo.mockResolvedValueOnce({ id: 'mB', rid: RID, ts: 200 });
+ const { result } = renderJumpToMessage(listContainerRef);
+
+ await act(async () => {
+ const first = result.current.jumpToMessage('mA');
+ await result.current.jumpToMessage('mB');
+ resolveFirst({ id: 'mA', rid: RID, ts: 100 });
+ await first;
+ });
+
+ expect(listContainerRef.current.jumpToMessage).toHaveBeenCalledTimes(1);
+ expect(listContainerRef.current.jumpToMessage).toHaveBeenCalledWith('mB', null);
+ });
+
+ it('keeps the newer jump alive when an earlier one fails after it started', async () => {
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ let rejectFirst: (error: unknown) => void = () => {};
+ mockGetMessageInfo.mockReturnValueOnce(new Promise((_, rej) => (rejectFirst = rej)));
+ let resolveSecond: (value: unknown) => void = () => {};
+ mockGetMessageInfo.mockReturnValueOnce(new Promise(res => (resolveSecond = res)));
+ const { result } = renderJumpToMessage(listContainerRef);
+
+ await act(async () => {
+ const first = result.current.jumpToMessage('mA');
+ const second = result.current.jumpToMessage('mB');
+ rejectFirst(new Error('boom'));
+ await first;
+ resolveSecond({ id: 'mB', rid: RID, ts: 200 });
+ await second;
+ });
+
+ expect(mockLog).not.toHaveBeenCalled();
+ expect(listContainerRef.current.cancelJumpToMessage).not.toHaveBeenCalled();
+ expect(listContainerRef.current.jumpToMessage).toHaveBeenCalledWith('mB', null);
+ });
+
it('logs and cancels the jump on an unexpected error', async () => {
const listContainerRef = createListRef();
const error = new Error('boom');
@@ -287,6 +424,72 @@ describe('useJumpToMessage', () => {
expect(mockGetMessageInfo).toHaveBeenCalledWith('msg-2');
});
+ it('defers a jumpToMessageId param change in a thread until onThreadMessagesLoaded', async () => {
+ mockRouteParams = {};
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ mockGetMessageInfo.mockResolvedValue({ id: 'msg-3', rid: RID, tmid: 'tmid-1', ts: 100 });
+ const { result, rerender } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1' });
+
+ mockRouteParams = { jumpToMessageId: 'msg-3' };
+ rerender({ tmid: 'tmid-1' });
+
+ expect(mockGetMessageInfo).not.toHaveBeenCalled();
+
+ await act(async () => {
+ result.current.onThreadMessagesLoaded();
+ await Promise.resolve();
+ });
+
+ expect(mockGetMessageInfo).toHaveBeenCalledTimes(1);
+ expect(mockGetMessageInfo).toHaveBeenCalledWith('msg-3');
+ expect(mockSetParams).toHaveBeenCalledWith({ jumpToMessageId: undefined });
+ });
+
+ it('fires a jumpToMessageId param change immediately once the thread messages are loaded', async () => {
+ mockRouteParams = {};
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ mockGetMessageInfo.mockResolvedValue({ id: 'msg-4', rid: RID, tmid: 'tmid-1', ts: 100 });
+ const { result, rerender } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1' });
+ act(() => {
+ result.current.onThreadMessagesLoaded();
+ });
+
+ mockRouteParams = { jumpToMessageId: 'msg-4' };
+ await act(async () => {
+ rerender({ tmid: 'tmid-1' });
+ });
+
+ expect(mockGetMessageInfo).toHaveBeenCalledTimes(1);
+ expect(mockGetMessageInfo).toHaveBeenCalledWith('msg-4');
+ expect(mockSetParams).toHaveBeenCalledWith({ jumpToMessageId: undefined });
+ });
+
+ it('keeps deferring a jumpToMessageId param change after switching to a thread that has not loaded', async () => {
+ mockRouteParams = {};
+ const listContainerRef = createListRef();
+ listContainerRef.current.isMessageInWindow.mockReturnValue(true);
+ mockGetMessageInfo.mockResolvedValue({ id: 'msg-5', rid: RID, tmid: 'tmid-2', ts: 100 });
+ const { result, rerender } = renderJumpToMessage(listContainerRef, { tmid: 'tmid-1' });
+ act(() => {
+ result.current.onThreadMessagesLoaded();
+ });
+
+ rerender({ tmid: 'tmid-2' });
+ mockRouteParams = { jumpToMessageId: 'msg-5' };
+ rerender({ tmid: 'tmid-2' });
+
+ expect(mockGetMessageInfo).not.toHaveBeenCalled();
+
+ await act(async () => {
+ result.current.onThreadMessagesLoaded();
+ });
+
+ expect(mockGetMessageInfo).toHaveBeenCalledTimes(1);
+ expect(mockGetMessageInfo).toHaveBeenCalledWith('msg-5');
+ });
+
it('navigates to the thread when the jumpToThreadId route param changes to a new value', () => {
mockRouteParams = {};
const listContainerRef = createListRef();
diff --git a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx
index 8378bd66db6..f312e7ed5aa 100644
--- a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx
+++ b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx
@@ -5,7 +5,6 @@ import { setReaction } from '../../../../lib/services/restApi';
import log from '../../../../lib/methods/helpers/log';
import { Review } from '../../../../lib/methods/helpers/review';
import { getMessageById } from '../../../../lib/database/services/Message';
-import { SubscriptionType } from '../../../../definitions';
import { createMessageActionStore } from '../../../../containers/message/stores/MessageActionStore';
import ReactionPicker from '../../components/ReactionPicker';
import { useMessageActions } from '../useMessageActions';
@@ -65,7 +64,6 @@ const renderMessageActions = (overrides: Partial = {},
hideActionSheet,
rid: RID,
tmid: undefined,
- roomUserId: 'room-user-1',
onThreadPress,
messageComposerRef: refs.messageComposerRef as any,
messageActionsRef: refs.messageActionsRef as any,
@@ -321,8 +319,8 @@ describe('useMessageActions', () => {
});
describe('reply', () => {
- it('onReplyInit navigates to the thread when one already exists', async () => {
- const message = { id: 'msg-1', tlm: '2024-01-01' };
+ it('onReplyInit delegates the message to the thread press', async () => {
+ const message = { id: 'msg-1', msg: 'hello' };
mockGetMessageById.mockResolvedValue(message);
const { result, onThreadPress, navigation } = renderMessageActions();
@@ -334,24 +332,6 @@ describe('useMessageActions', () => {
expect(navigation.push).not.toHaveBeenCalled();
});
- it('onReplyInit pushes a new thread RoomView when none exists yet', async () => {
- const message = { id: 'msg-1', msg: 'hello' };
- mockGetMessageById.mockResolvedValue(message);
- const { result, navigation } = renderMessageActions();
-
- await act(async () => {
- await result.current.onReplyInit('msg-1');
- });
-
- expect(navigation.push).toHaveBeenCalledWith('RoomView', {
- rid: RID,
- tmid: 'msg-1',
- name: 'hello',
- t: SubscriptionType.THREAD,
- roomUserId: 'room-user-1'
- });
- });
-
it('onReplyInit no-ops when the message cannot be found', async () => {
mockGetMessageById.mockResolvedValue(null);
const { result, navigation, onThreadPress } = renderMessageActions();
diff --git a/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx b/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx
index 81a71db46c3..0e2a0ecc53b 100644
--- a/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx
+++ b/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx
@@ -1,19 +1,14 @@
-import { act, renderHook, waitFor } from '@testing-library/react-native';
+import { renderHook } from '@testing-library/react-native';
import { createStore } from 'zustand';
-import { getRoutingConfig } from '../../../../lib/services/restApi';
import { usePermissions } from '../../../../lib/hooks/usePermissions';
-import { type IUseOmnichannelPermissionsParams, type RoomState, type RoomStore } from '../../definitions';
+import { type RoomState, type RoomStore } from '../../definitions';
import { useOmnichannelPermissions } from '../useOmnichannelPermissions';
-jest.mock('../../../../lib/services/restApi', () => ({
- getRoutingConfig: jest.fn()
-}));
jest.mock('../../../../lib/hooks/usePermissions', () => ({
usePermissions: jest.fn()
}));
-const mockGetRoutingConfig = getRoutingConfig as jest.Mock;
const mockUsePermissions = usePermissions as jest.Mock;
const makeRoomStore = (): RoomStore =>
@@ -26,161 +21,51 @@ const makeRoomStore = (): RoomStore =>
roomUserId: null,
canAutoTranslate: false,
canForwardGuest: false,
- canReturnQueue: false,
canViewCannedResponse: false,
- canPlaceLivechatOnHold: false,
+ lastMessageFromAgent: false,
init: jest.fn(),
join: jest.fn(),
joinRoom: jest.fn(() => Promise.resolve()),
resumeRoom: jest.fn(() => Promise.resolve())
}));
-const renderOmnichannelPermissions = (overrides: Partial = {}, roomStore = makeRoomStore()) => {
- const { result } = renderHook(() =>
- useOmnichannelPermissions({
- rid: 'rid-1',
- t: 'l',
- room: { rid: 'rid-1', t: 'l' },
- roomUpdate: {},
- joined: true,
- livechatAllowManualOnHold: true,
- roomStore,
- ...overrides
- })
- );
-
- return { result, roomStore };
-};
-
describe('useOmnichannelPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
- it('publishes the four flags into the store for a livechat room', async () => {
+ it('publishes the permission flags into the store for a livechat room', () => {
mockUsePermissions.mockReturnValue([true, true]);
- mockGetRoutingConfig.mockResolvedValue({ returnQueue: true });
+ const roomStore = makeRoomStore();
- const { roomStore } = renderOmnichannelPermissions({
- t: 'l',
- room: { rid: 'rid-1', t: 'l', lastMessage: { token: undefined, u: { _id: 'u1' } }, onHold: false } as any
- });
+ renderHook(() => useOmnichannelPermissions({ rid: 'rid-1', t: 'l', roomStore }));
expect(roomStore.getState().canForwardGuest).toBe(true);
expect(roomStore.getState().canViewCannedResponse).toBe(true);
-
- await waitFor(() => {
- expect(roomStore.getState().canReturnQueue).toBe(true);
- });
-
- expect(roomStore.getState().canPlaceLivechatOnHold).toBe(true);
});
- it('does not touch the flags for a non-livechat room', async () => {
+ it('clears the flags when the room stops being a livechat room', () => {
mockUsePermissions.mockReturnValue([true, true]);
-
- const { roomStore } = renderOmnichannelPermissions({ t: 'c', room: { rid: 'rid-1', t: 'c' } as any });
-
- await act(async () => {});
-
- expect(mockGetRoutingConfig).not.toHaveBeenCalled();
- expect(roomStore.getState().canForwardGuest).toBe(false);
- expect(roomStore.getState().canReturnQueue).toBe(false);
- expect(roomStore.getState().canViewCannedResponse).toBe(false);
- expect(roomStore.getState().canPlaceLivechatOnHold).toBe(false);
- });
-
- it('drops a return-queue result that resolves after the screen unmounted', async () => {
const roomStore = makeRoomStore();
- let resolveRoutingConfig: (value: { returnQueue: boolean }) => void = () => {};
- mockUsePermissions.mockReturnValue([true, true]);
- mockGetRoutingConfig.mockImplementation(
- () =>
- new Promise(resolve => {
- resolveRoutingConfig = resolve;
- })
- );
-
- const { unmount } = renderHook((props: IUseOmnichannelPermissionsParams) => useOmnichannelPermissions(props), {
- initialProps: {
- rid: 'rid-1',
- t: 'l',
- room: { rid: 'rid-1', t: 'l' } as any,
- roomUpdate: {},
- joined: false,
- livechatAllowManualOnHold: true,
- roomStore
- }
+ const { rerender } = renderHook(({ t }: { t: string }) => useOmnichannelPermissions({ rid: 'rid-1', t, roomStore }), {
+ initialProps: { t: 'l' }
});
+ expect(roomStore.getState().canForwardGuest).toBe(true);
- unmount();
- resolveRoutingConfig({ returnQueue: true });
- await act(async () => {});
+ rerender({ t: 'c' });
- expect(roomStore.getState().canReturnQueue).toBe(false);
+ expect(roomStore.getState().canForwardGuest).toBe(false);
+ expect(roomStore.getState().canViewCannedResponse).toBe(false);
});
- it('fetches the server-global routing config once, not again when the room is joined', async () => {
- const roomStore = makeRoomStore();
+ it('keeps the flags off for a non-livechat room', () => {
mockUsePermissions.mockReturnValue([true, true]);
- mockGetRoutingConfig.mockResolvedValue({ returnQueue: true });
-
- const baseProps: IUseOmnichannelPermissionsParams = {
- rid: 'rid-1',
- t: 'l',
- room: { rid: 'rid-1', t: 'l' } as any,
- roomUpdate: {},
- joined: false,
- livechatAllowManualOnHold: true,
- roomStore
- };
-
- const { rerender } = renderHook((props: IUseOmnichannelPermissionsParams) => useOmnichannelPermissions(props), {
- initialProps: baseProps
- });
-
- await waitFor(() => {
- expect(roomStore.getState().canReturnQueue).toBe(true);
- });
-
- rerender({ ...baseProps, joined: true });
- await act(async () => {});
-
- expect(mockGetRoutingConfig).toHaveBeenCalledTimes(1);
- });
-
- it('recomputes canPlaceLivechatOnHold on a pure on-hold flip of the same room instance', async () => {
const roomStore = makeRoomStore();
- mockUsePermissions.mockReturnValue([true, true]);
- mockGetRoutingConfig.mockResolvedValue({ returnQueue: true });
-
- // WatermelonDB re-emits the same mutated model instance; only roomUpdate is a fresh snapshot.
- const room = { rid: 'rid-1', t: 'l', lastMessage: { token: undefined, u: { _id: 'u1' } }, onHold: false } as any;
-
- const baseProps: IUseOmnichannelPermissionsParams = {
- rid: 'rid-1',
- t: 'l',
- room,
- roomUpdate: { onHold: false },
- joined: true,
- livechatAllowManualOnHold: true,
- roomStore
- };
-
- const { rerender } = renderHook((props: IUseOmnichannelPermissionsParams) => useOmnichannelPermissions(props), {
- initialProps: baseProps
- });
- await waitFor(() => {
- expect(roomStore.getState().canPlaceLivechatOnHold).toBe(true);
- });
-
- room.onHold = true;
- rerender({ ...baseProps, roomUpdate: { onHold: true } });
+ renderHook(() => useOmnichannelPermissions({ rid: 'rid-1', t: 'c', roomStore }));
- await waitFor(() => {
- expect(roomStore.getState().canPlaceLivechatOnHold).toBe(false);
- });
+ expect(roomStore.getState().canForwardGuest).toBe(false);
+ expect(roomStore.getState().canViewCannedResponse).toBe(false);
});
});
diff --git a/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts b/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts
index e2938cbe209..c16e14dc018 100644
--- a/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts
+++ b/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts
@@ -2,7 +2,6 @@ import { InteractionManager } from 'react-native';
import { createStore } from 'zustand';
import { act, renderHook, waitFor } from '@testing-library/react-native';
-import { createMessageActionStore } from '../../../../containers/message/stores/MessageActionStore';
import { type RoomState, type RoomStore, type TRoomInitResult } from '../../definitions';
import { useRoomInit } from '../useRoomInit';
@@ -13,10 +12,7 @@ interface IRenderRoomInitParams {
tmid?: string;
isAuthenticated: boolean;
roomStore: RoomStore;
- roomUpdate: { status?: string };
onThreadMessagesLoaded: () => void;
- messageActionStore: ReturnType;
- onQuoteInit: (messageId: string) => void;
}
const makeRoomStore = (): RoomStore =>
@@ -29,9 +25,8 @@ const makeRoomStore = (): RoomStore =>
roomUserId: null,
canAutoTranslate: false,
canForwardGuest: false,
- canReturnQueue: false,
canViewCannedResponse: false,
- canPlaceLivechatOnHold: false,
+ lastMessageFromAgent: false,
init: jest.fn(() => Promise.resolve({ status: 'loaded', lastSeen: null })),
join: jest.fn(),
joinRoom: jest.fn(() => Promise.resolve()),
@@ -67,10 +62,7 @@ const renderRoomInit = (overrides: Partial = {}, roomStor
tmid: undefined,
isAuthenticated: true,
roomStore,
- roomUpdate: {},
onThreadMessagesLoaded: jest.fn(),
- messageActionStore: createMessageActionStore(),
- onQuoteInit: jest.fn(),
...overrides
};
const { rerender, result, unmount } = renderHook((props: IRenderRoomInitParams) => useRoomInit(props), {
@@ -115,44 +107,6 @@ describe('useRoomInit', () => {
expect(roomStore.getState().init).not.toHaveBeenCalled();
});
- it('re-initializes the room store when the room transitions out of INVITED status', () => {
- const roomStore = makeRoomStore();
- const { rerender } = renderRoomInit({ roomUpdate: { status: 'INVITED' } }, roomStore);
-
- expect(roomStore.getState().init).toHaveBeenCalledTimes(1);
-
- rerender({ roomUpdate: { status: 'READY' } });
-
- expect(roomStore.getState().init).toHaveBeenCalledTimes(2);
- });
-
- it('does not re-initialize the room store when the status changes without having been INVITED', () => {
- const roomStore = makeRoomStore();
- const { rerender } = renderRoomInit({ roomUpdate: { status: 'READY' } }, roomStore);
-
- expect(roomStore.getState().init).toHaveBeenCalledTimes(1);
-
- rerender({ roomUpdate: { status: 'ANOTHER' } });
-
- expect(roomStore.getState().init).toHaveBeenCalledTimes(1);
- });
-
- it('fires onQuoteInit once on mount when the message action store has a single-message quote action', () => {
- const onQuoteInit = jest.fn();
- const messageActionStore = createMessageActionStore({ kind: 'quote', messageIds: ['msg-1'] });
- renderRoomInit({ onQuoteInit, messageActionStore });
-
- expect(onQuoteInit).toHaveBeenCalledTimes(1);
- expect(onQuoteInit).toHaveBeenCalledWith('msg-1');
- });
-
- it('does not fire onQuoteInit when there is no pending quote action', () => {
- const onQuoteInit = jest.fn();
- renderRoomInit({ onQuoteInit, messageActionStore: createMessageActionStore() });
-
- expect(onQuoteInit).not.toHaveBeenCalled();
- });
-
it('clears loading once init rejects', async () => {
const roomStore = makeRoomStore();
roomStore.setState({ init: jest.fn(() => Promise.reject(new Error('boom'))) });
@@ -205,10 +159,11 @@ describe('useRoomInit', () => {
const stale = new Date('2026-01-01T00:00:00.000Z');
const fresh = new Date('2026-02-02T00:00:00.000Z');
const { roomStore, resolveInit } = makeDeferredRoomStore();
- const { result, rerender } = renderRoomInit({ roomUpdate: { status: 'INVITED' } }, roomStore);
+ const { result } = renderRoomInit({}, roomStore);
- // The INVITED transition starts a second run while the mount run is still in flight.
- rerender({ roomUpdate: { status: 'READY' } });
+ await act(async () => {
+ result.current.retry();
+ });
expect(roomStore.getState().init).toHaveBeenCalledTimes(2);
await resolveInit(0, stale);
@@ -225,12 +180,14 @@ describe('useRoomInit', () => {
it.each([['failed'], ['skipped']] as const)('leaves lastSeen untouched when init reports %s', async status => {
const loaded = new Date('2026-01-01T00:00:00.000Z');
const { roomStore, resolveInit, resolveInitWith } = makeDeferredRoomStore();
- const { result, rerender } = renderRoomInit({ roomUpdate: { status: 'INVITED' } }, roomStore);
+ const { result } = renderRoomInit({}, roomStore);
await resolveInit(0, loaded);
expect(result.current.lastSeen).toBe(loaded);
- rerender({ roomUpdate: { status: 'READY' } });
+ await act(async () => {
+ result.current.retry();
+ });
await resolveInitWith(1, { status });
expect(result.current.lastSeen).toBe(loaded);
@@ -246,4 +203,53 @@ describe('useRoomInit', () => {
expect(result.current.loading).toBe(true);
});
+
+ it('reports failed once a failed run settles', async () => {
+ const { roomStore, resolveInitWith } = makeDeferredRoomStore();
+ const { result } = renderRoomInit({}, roomStore);
+
+ expect(result.current.failed).toBe(false);
+
+ await resolveInitWith(0, { status: 'failed' });
+
+ expect(result.current.failed).toBe(true);
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('clears failed and runs init again on retry', async () => {
+ const { roomStore, resolveInitWith, resolveInit } = makeDeferredRoomStore();
+ const { result } = renderRoomInit({}, roomStore);
+
+ await resolveInitWith(0, { status: 'failed' });
+ expect(result.current.failed).toBe(true);
+
+ await act(async () => {
+ result.current.retry();
+ });
+ expect(result.current.failed).toBe(false);
+
+ await resolveInit(1);
+ expect(result.current.failed).toBe(false);
+ });
+
+ it('stops reporting failed once the screen has no init work left', async () => {
+ const { roomStore, resolveInitWith } = makeDeferredRoomStore();
+ const { result, rerender } = renderRoomInit({}, roomStore);
+
+ await resolveInitWith(0, { status: 'failed' });
+ expect(result.current.failed).toBe(true);
+
+ rerender({ isAuthenticated: false });
+
+ expect(result.current.failed).toBe(false);
+ });
+
+ it('does not report failed for a skipped run', async () => {
+ const { roomStore, resolveInitWith } = makeDeferredRoomStore();
+ const { result } = renderRoomInit({}, roomStore);
+
+ await resolveInitWith(0, { status: 'skipped' });
+
+ expect(result.current.failed).toBe(false);
+ });
});
diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx
index 0e1ad5bcee0..e5048c88c8a 100644
--- a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx
+++ b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx
@@ -3,13 +3,9 @@ import { createStore } from 'zustand';
import { toggleFollowMessage } from '../../../../lib/services/restApi';
import { replyBroadcast as replyBroadcastAction } from '../../../../actions/messages';
-import { Review } from '../../../../lib/methods/helpers/review';
-import { sendMessage } from '../../../../lib/methods/sendMessage';
import { getUserSelector } from '../../../../selectors/login';
import { type RoomState, type RoomStore } from '../../definitions';
import { RoomStoreContext } from '../../stores/RoomStoreContext';
-import { RoomScreenContext } from '../../stores/RoomScreenContext';
-import { createMessageActionStore, MessageActionStoreContext } from '../../../../containers/message/stores/MessageActionStore';
import { useRoomMessageHandlers } from '../useRoomMessageHandlers';
jest.mock('@react-navigation/native', () => ({
@@ -56,11 +52,13 @@ jest.mock('../../../../lib/methods/getThreadName', () => ({
const mockNavigation = { navigate: jest.fn(), push: jest.fn() };
const mockDispatch = jest.fn();
const mockShowActionSheet = jest.fn();
+const mockOnThreadPress = jest.fn();
+const mockOnReactionPress = jest.fn();
+const mockSendRoomMessage = jest.fn();
const mockHideActionSheet = jest.fn();
const mockToggleFollowMessage = toggleFollowMessage as jest.Mock;
const mockReplyBroadcastAction = replyBroadcastAction as jest.Mock;
-const mockSendMessage = sendMessage as jest.Mock;
const mockUser = { id: 'u1', username: 'user', token: 'tok', showMessageInMainThread: false };
@@ -76,9 +74,8 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore =>
roomUserId: null,
canAutoTranslate: false,
canForwardGuest: false,
- canReturnQueue: false,
canViewCannedResponse: false,
- canPlaceLivechatOnHold: false,
+ lastMessageFromAgent: false,
init: jest.fn(),
join: jest.fn(),
joinRoom: jest.fn(() => Promise.resolve()),
@@ -88,23 +85,31 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore =>
const renderRoomMessageHandlers = (roomStoreOverrides: Partial = {}, tmid?: string) => {
const roomStore = makeRoomStore(roomStoreOverrides);
- const messageActionStore = createMessageActionStore();
- const clearLastSeen = jest.fn();
-
- const { result } = renderHook(() => useRoomMessageHandlers(tmid), {
- wrapper: ({ children }) => (
-
-
- {children}
-
-
- )
- });
- return { result, roomStore, messageActionStore, clearLastSeen };
+ const { result } = renderHook(
+ () =>
+ useRoomMessageHandlers({
+ tmid,
+ onThreadPress: mockOnThreadPress,
+ onReactionPress: mockOnReactionPress,
+ sendMessage: mockSendRoomMessage
+ }),
+ {
+ wrapper: ({ children }) => {children}
+ }
+ );
+
+ return { result, roomStore };
};
-const renderWithoutStores = () => renderHook(() => useRoomMessageHandlers());
+const renderWithoutStores = () =>
+ renderHook(() =>
+ useRoomMessageHandlers({
+ onThreadPress: mockOnThreadPress,
+ onReactionPress: mockOnReactionPress,
+ sendMessage: jest.fn()
+ })
+ );
describe('useRoomMessageHandlers', () => {
beforeEach(() => {
@@ -158,32 +163,6 @@ describe('useRoomMessageHandlers', () => {
});
});
- describe('onAnswerButtonPress', () => {
- it('sends the message, clears the unread divider and reports a positive review event', async () => {
- mockSendMessage.mockResolvedValue(undefined);
- const { result, clearLastSeen, messageActionStore } = renderRoomMessageHandlers({}, 'thread-1');
- messageActionStore.getState().actions.startEditing('msg-1');
-
- await act(async () => {
- result.current.onAnswerButtonPress('hello', true);
- await Promise.resolve();
- });
-
- expect(mockSendMessage).toHaveBeenCalledWith('rid-1', 'hello', 'thread-1', mockUser, true);
- expect(clearLastSeen).toHaveBeenCalledTimes(1);
- expect(Review.pushPositiveEvent).toHaveBeenCalledTimes(1);
- expect(messageActionStore.getState().action).toBeNull();
- });
-
- it('no-ops when the message is undefined', () => {
- const { result } = renderRoomMessageHandlers();
-
- act(() => result.current.onAnswerButtonPress(undefined));
-
- expect(mockSendMessage).not.toHaveBeenCalled();
- });
- });
-
describe('store contexts absent', () => {
let consoleErrorSpy: jest.SpyInstance;
@@ -195,8 +174,8 @@ describe('useRoomMessageHandlers', () => {
consoleErrorSpy.mockRestore();
});
- it('throws when the screen and room contexts are absent', () => {
- expect(() => renderWithoutStores()).toThrow(/must be used within a Room(Screen|Store)Context\.Provider/);
+ it('throws when the room store context is absent', () => {
+ expect(() => renderWithoutStores()).toThrow(/must be used within a RoomStoreContext\.Provider/);
});
});
});
diff --git a/app/views/RoomView/hooks/useCanPlaceLivechatOnHold.ts b/app/views/RoomView/hooks/useCanPlaceLivechatOnHold.ts
new file mode 100644
index 00000000000..3a130713aea
--- /dev/null
+++ b/app/views/RoomView/hooks/useCanPlaceLivechatOnHold.ts
@@ -0,0 +1,10 @@
+import { useSetting } from '../../../lib/hooks/useSetting';
+import { useRoomStoreByRid } from '../stores/RoomStore';
+
+export function useCanPlaceLivechatOnHold(rid?: string): boolean {
+ const livechatAllowManualOnHold = useSetting('Livechat_allow_manual_on_hold') as boolean;
+ const t = useRoomStoreByRid(rid, s => s.room.t);
+ const lastMessageFromAgent = useRoomStoreByRid(rid, s => s.lastMessageFromAgent);
+ const onHold = useRoomStoreByRid(rid, s => s.roomUpdate.onHold);
+ return t === 'l' && !!livechatAllowManualOnHold && lastMessageFromAgent && !onHold;
+}
diff --git a/app/views/RoomView/hooks/useGoRoomActionsView.ts b/app/views/RoomView/hooks/useGoRoomActionsView.ts
index 8a20bbd0c8f..a401cba0937 100644
--- a/app/views/RoomView/hooks/useGoRoomActionsView.ts
+++ b/app/views/RoomView/hooks/useGoRoomActionsView.ts
@@ -2,11 +2,13 @@ import { type NavigatorScreenParams, useNavigation } from '@react-navigation/nat
import { events, logEvent } from '../../../lib/methods/helpers/log';
import { useMasterDetail } from '../../../lib/hooks/useMasterDetail';
+import { useCanReturnQueue } from '../../../ee/omnichannel/hooks/useCanReturnQueue';
import type { ISubscription, SubscriptionType, TSubscriptionModel } from '../../../definitions';
import { type TNavigation } from '../../../stacks/stackType';
import { type ModalStackParamList } from '../../../stacks/MasterDetailStack/types';
import { type IRoomViewProps } from '../definitions';
import { useRoomStoreByRid } from '../stores/RoomStore';
+import { useCanPlaceLivechatOnHold } from './useCanPlaceLivechatOnHold';
export const useGoRoomActionsView = (rid?: string): ((screen?: keyof ModalStackParamList) => void) => {
const navigation = useNavigation();
@@ -17,9 +19,9 @@ export const useGoRoomActionsView = (rid?: string): ((screen?: keyof ModalStackP
const member = useRoomStoreByRid(rid, s => s.member);
const joined = useRoomStoreByRid(rid, s => s.joined);
const canForwardGuest = useRoomStoreByRid(rid, s => s.canForwardGuest);
- const canReturnQueue = useRoomStoreByRid(rid, s => s.canReturnQueue);
const canViewCannedResponse = useRoomStoreByRid(rid, s => s.canViewCannedResponse);
- const canPlaceLivechatOnHold = useRoomStoreByRid(rid, s => s.canPlaceLivechatOnHold);
+ const canReturnQueue = useCanReturnQueue(t === 'l');
+ const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(rid);
const omnichannelPermissions = { canForwardGuest, canReturnQueue, canViewCannedResponse, canPlaceLivechatOnHold };
diff --git a/app/views/RoomView/hooks/useHeader.tsx b/app/views/RoomView/hooks/useHeader.tsx
index 3d8e7ca5b50..ec2b5acf516 100644
--- a/app/views/RoomView/hooks/useHeader.tsx
+++ b/app/views/RoomView/hooks/useHeader.tsx
@@ -76,15 +76,9 @@ export const useHeader = ({ rid, tmid, name: roomName }: IUseHeaderParams): void
const navigation = useNavigation();
const room = useRoomStoreByRid(rid, s => s.room);
- // last_message re-emits on every incoming message; exclude it so the title effect only re-fires
- // on fields the header actually renders.
- const titleUpdate = useRoomStoreByRid(
+ const roomUpdate = useRoomStoreByRid(
rid,
- useShallow(s => {
- const rest = { ...s.roomUpdate };
- delete rest.lastMessage;
- return rest;
- })
+ useShallow(s => s.roomUpdate)
);
const roomUserId = useRoomStoreByRid(rid, s => s.roomUserId);
const goRoomActionsView = useGoRoomActionsView(rid);
@@ -108,5 +102,5 @@ export const useHeader = ({ rid, tmid, name: roomName }: IUseHeaderParams): void
const headerProps = getRoomHeaderProps({ room, tmid, roomName, roomUserId, onPress: goRoomActionsView });
navigation.setOptions({ headerTitle: () => });
- }, [rid, tmid, roomName, room, titleUpdate, roomUserId, navigation, goRoomActionsView]);
+ }, [rid, tmid, roomName, room, roomUpdate, roomUserId, navigation, goRoomActionsView]);
};
diff --git a/app/views/RoomView/hooks/useJumpToMessage.ts b/app/views/RoomView/hooks/useJumpToMessage.ts
index 19996e9d374..c3e6977835d 100644
--- a/app/views/RoomView/hooks/useJumpToMessage.ts
+++ b/app/views/RoomView/hooks/useJumpToMessage.ts
@@ -33,14 +33,20 @@ export function useJumpToMessage({
const pendingJumpRef = useRef(route.params?.jumpToMessageId);
const jumpToThreadIdRef = useRef(route.params?.jumpToThreadId);
+ const loadedThreadRef = useRef(undefined);
- const cancelJumpToMessage = () => {
+ const jumpGenerationRef = useRef(0);
+
+ const cancelJumpToMessage = (): void => {
+ jumpGenerationRef.current += 1;
listContainerRef.current?.cancelJumpToMessage();
sendLoadingEvent({ visible: false });
};
- const jumpToMessage = (messageId: string, isFromReply?: boolean) =>
- jumpToMessageService({
+ const jumpToMessage = (messageId: string, isFromReply?: boolean): Promise => {
+ jumpGenerationRef.current += 1;
+ const generation = jumpGenerationRef.current;
+ return jumpToMessageService({
messageId,
isFromReply,
rid,
@@ -49,8 +55,10 @@ export function useJumpToMessage({
listContainerRef,
navToRoom,
navToThread,
- cancel: cancelJumpToMessage
+ cancel: cancelJumpToMessage,
+ isCancelled: () => jumpGenerationRef.current !== generation
});
+ };
// Fire a jump from a Navigation param, then consume the one-shot param so re-selecting the SAME
// message id reads as a change (undefined -> id edge) and re-fires, instead of matching a stale
@@ -64,15 +72,23 @@ export function useJumpToMessage({
// Thread jump: fired from the store init's `onThreadMessagesLoaded` callback — the thread window is
// populated by then, so the row exists (a non-anchored thread jump otherwise aborts and parks on the live tail).
const onThreadMessagesLoaded = () => {
+ loadedThreadRef.current = tmid;
if (pendingJumpRef.current) {
- const messageId = pendingJumpRef.current;
- pendingJumpRef.current = undefined;
+ consumeJumpParam(pendingJumpRef.current);
+ }
+ };
+
+ const onJumpParamChanged = (messageId: string) => {
+ if (!tmid || loadedThreadRef.current === tmid) {
consumeJumpParam(messageId);
+ } else {
+ pendingJumpRef.current = messageId;
}
};
// Live-mirrored (see useLiveRef) so the mount effect can key on [tmid] despite these being unstable.
const consumeJumpParamRef = useLiveRef(consumeJumpParam);
+ const onJumpParamChangedRef = useLiveRef(onJumpParamChanged);
const navToThreadRef = useLiveRef(navToThread);
useEffect(() => {
@@ -98,7 +114,7 @@ export function useJumpToMessage({
return () => task.cancel();
}, [navToThreadRef]);
- useChangedParam(route.params?.jumpToMessageId, id => consumeJumpParamRef.current(id));
+ useChangedParam(route.params?.jumpToMessageId, id => onJumpParamChangedRef.current(id));
useChangedParam(route.params?.jumpToThreadId, id => navToThreadRef.current({ tmid: id }));
return { jumpToMessage, cancelJumpToMessage, onThreadMessagesLoaded };
diff --git a/app/views/RoomView/hooks/useMessageActions.tsx b/app/views/RoomView/hooks/useMessageActions.tsx
index ad8f6c1134b..64a13149239 100644
--- a/app/views/RoomView/hooks/useMessageActions.tsx
+++ b/app/views/RoomView/hooks/useMessageActions.tsx
@@ -1,11 +1,8 @@
-import { useNavigation } from '@react-navigation/native';
-
import { editMessage } from '../../../lib/methods/editMessage';
import log from '../../../lib/methods/helpers/log';
-import { makeThreadName } from '../../../lib/methods/helpers/room';
import { getMessageById } from '../../../lib/database/services/Message';
-import { type IMessage, type IMessageEditAttachment, SubscriptionType, type TAnyMessageModel } from '../../../definitions';
-import { type IRoomViewProps, type IUseMessageActionsParams, type IUseMessageActionsResult } from '../definitions';
+import { type IMessage, type IMessageEditAttachment, type TAnyMessageModel } from '../../../definitions';
+import { type IUseMessageActionsParams, type IUseMessageActionsResult } from '../definitions';
import ReactionPicker from '../components/ReactionPicker';
import { useReactionActions } from './useReactionActions';
@@ -15,14 +12,11 @@ export function useMessageActions({
hideActionSheet,
rid,
tmid,
- roomUserId,
onThreadPress,
messageComposerRef,
messageActionsRef,
messageErrorActionsRef
}: IUseMessageActionsParams): IUseMessageActionsResult {
- const navigation = useNavigation();
-
const { resetAction, onReactionClose, onReactionPress } = useReactionActions({ messageActionStore, hideActionSheet });
const handleCloseEmoji = (action?: (params?: unknown) => void, params?: unknown) => {
@@ -119,16 +113,7 @@ export function useMessageActions({
if (!message || !rid) {
return;
}
- if (message.tlm) {
- return onThreadPress(message);
- }
- navigation.push('RoomView', {
- rid,
- tmid: messageId,
- name: makeThreadName(message),
- t: SubscriptionType.THREAD,
- roomUserId
- });
+ onThreadPress(message);
};
const setQuotesAndText = (text: string, quotes: string[]) => {
diff --git a/app/views/RoomView/hooks/useOmnichannelPermissions.ts b/app/views/RoomView/hooks/useOmnichannelPermissions.ts
index 437b47a6b0a..58a04600334 100644
--- a/app/views/RoomView/hooks/useOmnichannelPermissions.ts
+++ b/app/views/RoomView/hooks/useOmnichannelPermissions.ts
@@ -1,80 +1,19 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
-import { getRoutingConfig } from '../../../lib/services/restApi';
import { usePermissions } from '../../../lib/hooks/usePermissions';
-import { type IRoomViewState, type IUseOmnichannelPermissionsParams } from '../definitions';
+import { type IUseOmnichannelPermissionsParams } from '../definitions';
-const getCanReturnQueue = async () => {
- try {
- const { returnQueue } = await getRoutingConfig();
- return returnQueue;
- } catch {
- return false;
- }
-};
-
-const getCanPlaceLivechatOnHold = (livechatAllowManualOnHold: boolean | undefined, room: IRoomViewState['room']) =>
- !!(livechatAllowManualOnHold && !room?.lastMessage?.token && room?.lastMessage?.u && !room.onHold);
-
-export function useOmnichannelPermissions({
- rid,
- t,
- room,
- roomUpdate,
- joined,
- livechatAllowManualOnHold,
- roomStore
-}: IUseOmnichannelPermissionsParams): void {
+export function useOmnichannelPermissions({ rid, t, roomStore }: IUseOmnichannelPermissionsParams): void {
+ const isLivechat = t === 'l';
const [canForwardGuest, canViewCannedResponse] = usePermissions(
['transfer-livechat-guest', 'view-canned-responses'],
- t === 'l' ? rid : undefined
+ isLivechat ? rid : undefined
);
useEffect(() => {
- if (t !== 'l') {
- return;
- }
- roomStore.setState({ canForwardGuest, canViewCannedResponse });
- }, [t, canForwardGuest, canViewCannedResponse, roomStore]);
-
- const [canReturnQueue, setCanReturnQueue] = useState(false);
-
- // The routing config is server-global, so it is fetched once per screen rather than again on
- // every rid swap or join.
- useEffect(() => {
- if (t !== 'l') {
- return;
- }
- let cancelled = false;
- getCanReturnQueue().then(returnQueue => {
- if (cancelled) {
- return;
- }
- setCanReturnQueue(returnQueue);
- });
- return () => {
- cancelled = true;
- };
- }, [t]);
-
- useEffect(() => {
- if (t !== 'l') {
- return;
- }
roomStore.setState({
- canReturnQueue,
- canPlaceLivechatOnHold: getCanPlaceLivechatOnHold(livechatAllowManualOnHold, room)
+ canForwardGuest: isLivechat && canForwardGuest,
+ canViewCannedResponse: isLivechat && canViewCannedResponse
});
- }, [
- t,
- room,
- roomStore,
- canReturnQueue,
- livechatAllowManualOnHold,
- roomUpdate.lastMessage?.token,
- roomUpdate.visitor,
- roomUpdate.status,
- roomUpdate.onHold,
- joined
- ]);
+ }, [isLivechat, canForwardGuest, canViewCannedResponse, roomStore]);
}
diff --git a/app/views/RoomView/hooks/useRoomInit.ts b/app/views/RoomView/hooks/useRoomInit.ts
index 6ca4f13bcf1..c92bdd161e7 100644
--- a/app/views/RoomView/hooks/useRoomInit.ts
+++ b/app/views/RoomView/hooks/useRoomInit.ts
@@ -3,7 +3,6 @@ import { InteractionManager } from 'react-native';
import { useLiveRef } from '../../../lib/hooks/useLiveRef';
import log from '../../../lib/methods/helpers/log';
-import { type TMessageActionStore } from '../../../containers/message/stores/MessageActionStore';
import { type IRoomScreenContextValue, type IRoomViewState, type RoomStore } from '../definitions';
interface IUseRoomInitParams {
@@ -11,10 +10,13 @@ interface IUseRoomInitParams {
tmid?: string;
isAuthenticated: boolean;
roomStore: RoomStore;
- roomUpdate: IRoomViewState['roomUpdate'];
onThreadMessagesLoaded: () => void;
- messageActionStore: TMessageActionStore;
- onQuoteInit: (messageId: string) => void;
+}
+
+interface IRunInitSetters {
+ setSettled: (settled: boolean) => void;
+ setLastSeen: (lastSeen: IRoomViewState['lastSeen']) => void;
+ setFailed: (failed: boolean) => void;
}
// Marks the screen unsettled for the duration of one init() run. init() resolves on the invite
@@ -29,8 +31,7 @@ const runInit = async (
tmid: string | undefined,
onLoadedRef: RefObject<() => void>,
controller: AbortController,
- setSettled: (settled: boolean) => void,
- setLastSeen: (lastSeen: IRoomViewState['lastSeen']) => void
+ { setSettled, setLastSeen, setFailed }: IRunInitSetters
): Promise => {
setSettled(false);
try {
@@ -39,12 +40,17 @@ const runInit = async (
onThreadMessagesLoaded: () => onLoadedRef.current?.(),
signal: controller.signal
});
- // Only a loaded run carries an anchor; `failed` and `skipped` leave the current one alone.
- if (!controller.signal.aborted && result.status === 'loaded') {
- setLastSeen(result.lastSeen);
+ if (!controller.signal.aborted) {
+ if (result.status === 'loaded') {
+ setLastSeen(result.lastSeen);
+ }
+ setFailed(result.status === 'failed');
}
} catch (e) {
log(e);
+ if (!controller.signal.aborted) {
+ setFailed(true);
+ }
} finally {
if (!controller.signal.aborted) {
setSettled(true);
@@ -57,21 +63,18 @@ export function useRoomInit({
tmid,
isAuthenticated,
roomStore,
- roomUpdate,
- onThreadMessagesLoaded,
- messageActionStore,
- onQuoteInit
+ onThreadMessagesLoaded
}: IUseRoomInitParams): IRoomScreenContextValue {
// onThreadMessagesLoaded is recreated every render; a live ref keeps it out of the init effects'
// deps so they don't re-fire on identity change alone (see ticket NATIVE-1356).
const onLoadedRef = useLiveRef(onThreadMessagesLoaded);
- const onQuoteInitRef = useLiveRef(onQuoteInit);
// The unread divider anchor belongs to this screen, not to the room — see stores/RoomScreenContext.
const [lastSeen, setLastSeen] = useState(null);
// `settled` tracks the init run, and only the init run. A screen that has no rid or no auth never
// starts one, so `loading` is derived from both: no work pending means idle, never a stuck flag.
const [settled, setSettled] = useState(false);
+ const [failed, setFailed] = useState(false);
const hasInitWork = !!rid && isAuthenticated;
const loading = hasInitWork && !settled;
// One controller per init() run. A new run aborts the one it supersedes and never resets it, so a
@@ -82,7 +85,8 @@ export function useRoomInit({
initControllerRef.current?.abort();
const controller = new AbortController();
initControllerRef.current = controller;
- return runInit(roomStore, tmid, onLoadedRef, controller, setSettled, setLastSeen);
+ setFailed(false);
+ return runInit(roomStore, tmid, onLoadedRef, controller, { setSettled, setLastSeen, setFailed });
}, [roomStore, tmid, onLoadedRef]);
const clearLastSeen = useCallback(() => setLastSeen(null), []);
@@ -103,25 +107,5 @@ export function useRoomInit({
// rid and isAuthenticated stay in the deps: hasInitWork alone would not re-fire on a rid swap.
}, [rid, isAuthenticated, hasInitWork, init]);
- // messageActionStore is useState-stable, so this fires once per screen.
- useEffect(() => {
- const task = InteractionManager.runAfterInteractions(() => {
- const { action } = messageActionStore.getState();
- if (action?.kind === 'quote' && action.messageIds.length === 1) {
- onQuoteInitRef.current(action.messageIds[0]);
- }
- });
- return () => task.cancel();
- }, [messageActionStore, onQuoteInitRef]);
-
- // init() is skipped for invite subscriptions. Initialize when invite has been accepted
- const prevStatusRef = useRef(roomUpdate.status);
- useEffect(() => {
- if (prevStatusRef.current === 'INVITED' && roomUpdate.status !== 'INVITED') {
- init();
- }
- prevStatusRef.current = roomUpdate.status;
- }, [roomUpdate.status, init]);
-
- return { loading, lastSeen, clearLastSeen };
+ return { loading, failed: hasInitWork && failed && !loading, retry: init, lastSeen, clearLastSeen };
}
diff --git a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx
index 0a347027d2b..7050001d6c0 100644
--- a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx
+++ b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx
@@ -1,4 +1,3 @@
-import { useContext } from 'react';
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
@@ -10,9 +9,7 @@ import { goRoom, type TGoRoomItem } from '../../../lib/methods/helpers/goRoom';
import { showErrorAlert } from '../../../lib/methods/helpers/info';
import { events, logEvent } from '../../../lib/methods/helpers/log';
import { isInActiveVoipCall } from '../../../lib/services/voip/isInActiveVoipCall';
-import { useAppSelector } from '../../../lib/hooks/useAppSelector';
import { useMasterDetail } from '../../../lib/hooks/useMasterDetail';
-import { getUserSelector } from '../../../selectors/login';
import {
type IMessage,
type IRoomInfoParam,
@@ -22,35 +19,25 @@ import {
} from '../../../definitions';
import { useActionSheet } from '../../../containers/ActionSheet';
import ReactionsList from '../../../containers/ReactionsList';
-import { MessageActionStoreContext } from '../../../containers/message/stores/MessageActionStore';
-import { type IRoomViewProps } from '../definitions';
+import { type IRoomActions, type IRoomViewProps } from '../definitions';
import { useRoomStore } from '../stores/RoomStoreContext';
-import { useRoomScreen } from '../stores/RoomScreenContext';
import { blockAction as blockActionService } from '../services/blockAction';
import { fetchThreadName as fetchThreadNameService } from '../services/fetchThreadName';
import { toggleFollowThread as toggleFollowThreadService } from '../../../lib/methods/toggleFollowThread';
-import { pushThreadRoom } from '../services/pushThreadRoom';
-import { sendRoomMessage } from '../services/sendRoomMessage';
-import { useReactionActions } from './useReactionActions';
-export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersResult {
+export function useRoomMessageHandlers({
+ tmid,
+ onThreadPress,
+ onReactionPress,
+ sendMessage
+}: IRoomActions & { tmid?: string }): IUseRoomMessageHandlersResult {
const navigation = useNavigation();
const dispatch = useDispatch();
const isMasterDetail = useMasterDetail();
- const user = useAppSelector(getUserSelector);
- const { showActionSheet, hideActionSheet } = useActionSheet();
+ const { showActionSheet } = useActionSheet();
- const messageActionStore = useContext(MessageActionStoreContext);
- const { clearLastSeen } = useRoomScreen();
const rid = useRoomStore(s => s.room.rid);
const room = useRoomStore(s => s.room);
- const roomUserId = useRoomStore(s => s.roomUserId);
-
- if (!messageActionStore) {
- throw new Error('useRoomMessageHandlers must be used within a MessageActionProvider');
- }
-
- const { resetAction, onReactionPress } = useReactionActions({ messageActionStore, hideActionSheet });
const onDiscussionPress = async (drid: TAnyMessageModel['drid']) => {
if (!drid) return;
@@ -63,9 +50,6 @@ export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersRe
}
};
- // No orchestrator-owned cancelJumpToMessageRef to self-source here, so the loading overlay renders without a cancel button.
- const onThreadPress = (item: TAnyMessageModel) => pushThreadRoom({ rid, item, roomUserId, navigation });
-
const navToRoomInfo = (navParam: IRoomInfoParam) => {
logEvent(events[`ROOM_GO_${navParam.t === SubscriptionType.DIRECT ? 'USER' : 'ROOM'}_INFO`]);
const params = { ...navParam, fromRid: rid };
@@ -125,9 +109,6 @@ export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersRe
return toggleFollowThreadService(threadMessageId, isFollowingThread);
};
- const onAnswerButtonPress = (message?: string, tshow?: boolean) =>
- sendRoomMessage({ rid, message, tmid, user, tshow, onMessageSent: clearLastSeen, resetAction });
-
return {
blockAction: blockActionService,
navToRoomInfo,
@@ -141,6 +122,6 @@ export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersRe
replyBroadcast,
fetchThreadName,
toggleFollowThread,
- onAnswerButtonPress
+ onAnswerButtonPress: sendMessage
};
}
diff --git a/app/views/RoomView/hooks/useRoomMessaging.ts b/app/views/RoomView/hooks/useRoomMessaging.ts
new file mode 100644
index 00000000000..11f7eca4598
--- /dev/null
+++ b/app/views/RoomView/hooks/useRoomMessaging.ts
@@ -0,0 +1,123 @@
+import { useRef, useState } from 'react';
+
+import { type IMessageActions } from '../../../containers/MessageActions';
+import { type IMessageErrorActions } from '../../../containers/MessageErrorActions';
+import { type IMessageComposerRef } from '../../../containers/MessageComposer';
+import { useActionSheet } from '../../../containers/ActionSheet';
+import { createMessageActionStore } from '../../../containers/message/stores/MessageActionStore';
+import { useAppSelector } from '../../../lib/hooks/useAppSelector';
+import { useLiveRef } from '../../../lib/hooks/useLiveRef';
+import { useMasterDetail } from '../../../lib/hooks/useMasterDetail';
+import { getUserSelector } from '../../../selectors/login';
+import {
+ type IListContainerRef,
+ type IUseRoomMessagingParams,
+ type IUseRoomMessagingResult,
+ type TListRef
+} from '../definitions';
+import { sendRoomMessage } from '../services/sendRoomMessage';
+import { useMessageActions } from './useMessageActions';
+import { useRoomInit } from './useRoomInit';
+import { useRoomNavigation } from './useRoomNavigation';
+
+export function useRoomMessaging({
+ rid,
+ t,
+ tmid,
+ roomStore,
+ roomUserId,
+ quoteMessageId
+}: IUseRoomMessagingParams): IUseRoomMessagingResult {
+ const isAuthenticated = useAppSelector(state => state.login.isAuthenticated);
+ const user = useAppSelector(getUserSelector);
+ const isMasterDetail = useMasterDetail();
+ const { showActionSheet, hideActionSheet } = useActionSheet();
+
+ const [messageActionStore] = useState(() =>
+ createMessageActionStore(quoteMessageId ? { kind: 'quote', messageIds: [quoteMessageId] } : null)
+ );
+
+ const messageComposerRef = useRef(null);
+ const listContainerRef = useRef(null);
+ const flatListRef: TListRef = useRef(null);
+ const messageActionsRef = useRef(null);
+ const messageErrorActionsRef = useRef(null);
+
+ const userRef = useLiveRef(user);
+ const roomUserIdRef = useLiveRef(roomUserId);
+
+ const { onThreadMessagesLoaded, onThreadPress, jumpToMessageByUrl } = useRoomNavigation({
+ rid,
+ tmid,
+ t,
+ isMasterDetail,
+ listContainerRef,
+ roomUserIdRef
+ });
+
+ const {
+ resetAction,
+ handleCloseEmoji,
+ errorActionsShow,
+ onEditInit,
+ onEditCancel,
+ onEditRequest,
+ onQuoteInit,
+ onRemoveQuoteMessage,
+ onReactionPress,
+ onReactionInit,
+ onMessageLongPress,
+ onReplyInit,
+ setQuotesAndText,
+ getText
+ } = useMessageActions({
+ messageActionStore,
+ showActionSheet,
+ hideActionSheet,
+ rid,
+ tmid,
+ onThreadPress,
+ messageComposerRef,
+ messageActionsRef,
+ messageErrorActionsRef
+ });
+
+ const roomScreen = useRoomInit({ rid, tmid, isAuthenticated, roomStore, onThreadMessagesLoaded });
+
+ const sendMessage = (message?: string, tshow?: boolean) =>
+ sendRoomMessage({
+ rid,
+ message,
+ tmid,
+ user: userRef.current,
+ tshow,
+ onMessageSent: roomScreen.clearLastSeen,
+ resetAction
+ });
+
+ return {
+ messageActionStore,
+ roomScreen,
+ messageComposerRef,
+ listContainerRef,
+ flatListRef,
+ messageActionsRef,
+ messageErrorActionsRef,
+ roomActions: { onThreadPress, onReactionPress, sendMessage },
+ sendMessage,
+ jumpToMessage: jumpToMessageByUrl,
+ closeEmojiAndAction: handleCloseEmoji,
+ errorActionsShow,
+ onMessageLongPress,
+ onEditInit,
+ onEditCancel,
+ onEditRequest,
+ onQuoteInit,
+ onRemoveQuoteMessage,
+ onReactionInit,
+ onReactionPress,
+ onReplyInit,
+ setQuotesAndText,
+ getText
+ };
+}
diff --git a/app/views/RoomView/hooks/useRoomSubscription.ts b/app/views/RoomView/hooks/useRoomSubscription.ts
index e9a1654d33f..ad80b421988 100644
--- a/app/views/RoomView/hooks/useRoomSubscription.ts
+++ b/app/views/RoomView/hooks/useRoomSubscription.ts
@@ -1,18 +1,20 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { InteractionManager } from 'react-native';
import log from '../../../lib/methods/helpers/log';
-import type RoomClass from '../../../lib/methods/subscriptions/room';
+import RoomClass from '../../../lib/methods/subscriptions/room';
-const safeSubscribe = (sub?: RoomClass) => {
+const safeSubscribe = (sub: RoomClass) => {
try {
- sub?.subscribe?.();
+ sub.subscribe?.();
} catch (e) {
log(e);
}
};
-export function useRoomSubscription(sub?: RoomClass): void {
+export function useRoomSubscription(rid: string | undefined, tmid: string | undefined): void {
+ const [sub] = useState(() => (rid && !tmid ? new RoomClass(rid) : undefined));
+
useEffect(() => {
if (!sub) {
return;
diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx
index 164b817f037..fd16426f221 100644
--- a/app/views/RoomView/index.tsx
+++ b/app/views/RoomView/index.tsx
@@ -1,318 +1,58 @@
-import { useRef, useState } from 'react';
-import { useWindowDimensions } from 'react-native';
-import { useStore } from 'zustand';
+import { useState } from 'react';
-import { type IMessageActions } from '../../containers/MessageActions';
-import { type IMessageErrorActions } from '../../containers/MessageErrorActions';
-import I18n from '../../i18n';
-import { useTheme } from '../../theme';
-import RoomClass from '../../lib/methods/subscriptions/room';
-import { sendRoomMessage } from './services/sendRoomMessage';
-import { getUserSelector } from '../../selectors/login';
-import SafeAreaView from '../../containers/SafeAreaView';
-import { useAppSelector } from '../../lib/hooks/useAppSelector';
-import { useSetting } from '../../lib/hooks/useSetting';
-import { useMasterDetail } from '../../lib/hooks/useMasterDetail';
-import Banner from './components/Banner';
-import JoinCode from './components/JoinCode';
-import UploadProgress from './components/UploadProgress';
-import List from './List';
-import { type RoomType } from '../../definitions';
-import { getUidDirectMessage, getRoomTitle } from '../../lib/methods/helpers';
-import { useActionSheet } from '../../containers/ActionSheet';
-import { type IMessageComposerRef } from '../../containers/MessageComposer';
-import { createMessageActionStore } from '../../containers/message/stores/MessageActionStore';
-import { RoomProviders } from './components/RoomProviders';
-import { RoomMessageProvider } from './components/RoomMessageProvider';
-import { A11yGateProvider } from '../../containers/message/stores/A11yGate';
-import { type IJoinCode, type IListContainerRef, type IRoomViewProps, type IRoomViewState, type TListRef } from './definitions';
+import { getRoomTitle } from '../../lib/methods/helpers';
+import { isInviteSubscription } from '../../lib/methods/isInviteSubscription';
+import { type IRoomScreenInput, type IRoomViewProps } from './definitions';
import { EncryptedRoom } from './components/EncryptedRoom';
-import { MissingRoomE2EEKey } from './components/MissingRoomE2EEKey';
-import { RoomFooter } from './components/RoomFooter/RoomFooter';
-import { RoomMessageActions } from './components/RoomMessageActions';
-import { isRoomFederated } from '../../lib/methods/isRoomFederated';
import { InvitedRoomScreen } from './components/InvitedRoomScreen';
-import { isInviteSubscription } from '../../lib/methods/isInviteSubscription';
+import { MissingRoomE2EEKey } from './components/MissingRoomE2EEKey';
+import { RoomRouteInvalid } from './components/RoomRouteInvalid';
+import RoomScreen from './RoomScreen';
+import { parseRoomRoute } from './services/parseRoomRoute';
import { useRoomStoreForScreen } from './stores/RoomStore';
-import { RoomStoreContext } from './stores/RoomStoreContext';
-import { RoomScreenContext } from './stores/RoomScreenContext';
-import { useHeader } from './hooks/useHeader';
-import { useMessageActions } from './hooks/useMessageActions';
+import { useRoomWithUpdateFromStore } from './stores/RoomStoreContext';
import { useE2EEStatus } from './hooks/useE2EEStatus';
-import { useRoomInit } from './hooks/useRoomInit';
-import { useRoomSubscription } from './hooks/useRoomSubscription';
-import { useRoomAudioLifecycle } from './hooks/useRoomAudioLifecycle';
-import { useRoomRemoved } from './hooks/useRoomRemoved';
-import { useRoomNavigation } from './hooks/useRoomNavigation';
-import { useOmnichannelPermissions } from './hooks/useOmnichannelPermissions';
-import { useInAppFeedback } from './hooks/useInAppFeedback';
-import { useCloseBanner } from './hooks/useCloseBanner';
-import { useLiveRef } from '../../lib/hooks/useLiveRef';
-
-const EMPTY_HIDE_SYSTEM_MESSAGES: string[] = [];
-
-// FIXME: handle servers with version < 3.0.0
-// Return stable refs (model field / redux prop / shared empty) — a fresh [] here re-subscribes
-// the message-list WatermelonDB query on every RoomView render (fetchMessages dep).
-const getHideSystemMessages = (room: IRoomViewState['room'], Hide_System_Messages?: string[]): string[] => {
- const { sysMes } = room;
- if (Array.isArray(sysMes)) {
- return sysMes;
- }
- if (Array.isArray(Hide_System_Messages)) {
- return Hide_System_Messages;
- }
- return EMPTY_HIDE_SYSTEM_MESSAGES;
-};
-
-const RoomView = (props: IRoomViewProps) => {
- const { colors } = useTheme();
-
- const { route, navigation } = props;
-
- const user = useAppSelector(getUserSelector);
- const isAuthenticated = useAppSelector(state => state.login.isAuthenticated);
- const baseUrl = useAppSelector(state => state.server.server);
- const serverVersion = useAppSelector(state => state.server.version);
- const Message_GroupingPeriod = useSetting('Message_GroupingPeriod') as number;
- const Message_Read_Receipt_Enabled = useSetting('Message_Read_Receipt_Enabled') as boolean;
- const Hide_System_Messages = useSetting('Hide_System_Messages') as string[];
- const livechatAllowManualOnHold = useSetting('Livechat_allow_manual_on_hold') as boolean;
- const { width } = useWindowDimensions();
- const isMasterDetail = useMasterDetail();
- const { showActionSheet, hideActionSheet } = useActionSheet();
-
- // Capture screen identity once at mount: navigation can transiently wipe this route's params to
- // undefined (e.g. popTo with no params while retained below the stack top), and a RoomView
- // instance's rid/t/tmid never legitimately change — reading reactively corrupts store refcount + send.
- const [rid] = useState(() => route.params?.rid);
- const [t] = useState(() => route.params?.t);
- /**
- * On threads, we don't have a subscription.
- * `room` is going to have only a few properties sent during navigation.
- * Use `tmid` as thread id.
- */
- const [tmid] = useState(() => route.params?.tmid);
- // On a thread this is the thread name, which the observed subscription row never carries.
- const [name] = useState(() => route.params?.name);
-
- const [messageActionStore] = useState(() => {
- const quoteMessageId = route.params?.messageId;
- return createMessageActionStore(quoteMessageId ? { kind: 'quote', messageIds: [quoteMessageId] } : null);
- });
-
- const [initialRoom] = useState(() => ({
- rid: rid as string,
- t: t as string,
- name,
- fname: route.params?.fname,
- prid: route.params?.prid,
- visitor: route.params?.visitor,
- joinCodeRequired: route.params?.joinCodeRequired
- }));
- const [initialRoomUserId] = useState(() => route.params?.roomUserId ?? getUidDirectMessage(initialRoom));
- const [sub] = useState(() => (rid && !tmid ? new RoomClass(rid) : undefined));
-
- const messageComposerRef = useRef(null);
- const joinCodeRef = useRef(null);
- const listContainerRef = useRef(null);
- const flatListRef: TListRef = useRef(null);
- const messageActionsRef = useRef(null);
- const messageErrorActionsRef = useRef(null);
-
- const userRef = useLiveRef(user);
-
- const roomStore = useRoomStoreForScreen({ rid, t, initialRoom, roomUserId: initialRoomUserId });
-
- const room = useStore(roomStore, s => s.room);
- const roomUpdate = useStore(roomStore, s => s.roomUpdate);
- const joined = useStore(roomStore, s => s.joined);
- const roomUserId = useStore(roomStore, s => s.roomUserId);
- const canAutoTranslate = useStore(roomStore, s => s.canAutoTranslate);
-
- const roomUserIdRef = useLiveRef(roomUserId);
-
- const hideSystemMessages = getHideSystemMessages(room, Hide_System_Messages);
-
- const { onThreadMessagesLoaded, onThreadPress, jumpToMessageByUrl } = useRoomNavigation({
- rid,
- tmid,
- t,
- isMasterDetail,
- listContainerRef,
- roomUserIdRef
- });
-
- const {
- resetAction,
- handleCloseEmoji,
- errorActionsShow,
- onEditInit,
- onEditCancel,
- onEditRequest,
- onQuoteInit,
- onRemoveQuoteMessage,
- onReactionPress,
- onReactionInit,
- onMessageLongPress,
- onReplyInit,
- setQuotesAndText,
- getText
- } = useMessageActions({
- messageActionStore,
- showActionSheet,
- hideActionSheet,
- rid,
- tmid,
- roomUserId,
- onThreadPress,
- messageComposerRef,
- messageActionsRef,
- messageErrorActionsRef
- });
-
- const roomScreen = useRoomInit({
- rid,
- tmid,
- isAuthenticated,
- roomStore,
- roomUpdate,
- onThreadMessagesLoaded,
- messageActionStore,
- onQuoteInit
- });
- useRoomSubscription(sub);
- useRoomAudioLifecycle(rid, tmid);
- useRoomRemoved(rid, isMasterDetail);
- const handleSendMessage = (message?: string, tshow?: boolean) =>
- sendRoomMessage({
- rid,
- message,
- tmid,
- user: userRef.current,
- tshow,
- onMessageSent: roomScreen.clearLastSeen,
- resetAction
- });
-
- const onJoin = () => {
- roomStore.getState().join();
- };
+import { useHeader } from './hooks/useHeader';
- useInAppFeedback();
+interface IRoomGateProps extends IRoomViewProps {
+ input: IRoomScreenInput;
+}
- const closeBanner = useCloseBanner(room);
+const RoomGate = ({ route, navigation, input }: IRoomGateProps) => {
+ const { rid, t, tmid, name, initialRoom, roomUserId } = input;
- useOmnichannelPermissions({
- rid,
- t,
- room,
- roomUpdate,
- joined,
- livechatAllowManualOnHold,
- roomStore
- });
+ const roomStore = useRoomStoreForScreen({ rid, initialRoom, roomUserId });
+ const room = useRoomWithUpdateFromStore(roomStore);
const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(rid);
useHeader({ rid, tmid, name });
- // Early returns below swap component types at this render position, so accepting an invite or
- // resolving the E2EE key remounts the room tree. Intentional: the blocked screens hold no state
- // worth keeping and the room tree mounts fresh once unblocked.
if ('id' in room && isInviteSubscription(room)) {
return ;
}
if ('encrypted' in room) {
- // Missing room encryption key
if (showMissingE2EEKey) {
return ;
}
- // Encrypted room, but user session is not encrypted
if (showE2EEDisabledRoom) {
return ;
}
}
- let bannerClosed;
- let announcement;
- if ('id' in room) {
- ({ bannerClosed, announcement } = room);
- }
+ return ;
+};
+
+const RoomView = ({ route, navigation }: IRoomViewProps) => {
+ const [parsed] = useState(() => parseRoomRoute(route.params));
- const federated = 'id' in room && isRoomFederated(room);
+ if (parsed.status === 'invalid') {
+ return ;
+ }
- return (
-
-
-
-
- {!tmid ? (
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return ;
};
export default RoomView;
diff --git a/app/views/RoomView/services/__tests__/fetchThreadName.test.ts b/app/views/RoomView/services/__tests__/fetchThreadName.test.ts
new file mode 100644
index 00000000000..03078f92967
--- /dev/null
+++ b/app/views/RoomView/services/__tests__/fetchThreadName.test.ts
@@ -0,0 +1,38 @@
+import { getThreadById } from '../../../../lib/database/services/Thread';
+import getThreadName from '../../../../lib/methods/getThreadName';
+import { fetchThreadName } from '../fetchThreadName';
+
+jest.mock('../../../../lib/database/services/Thread', () => ({
+ getThreadById: jest.fn()
+}));
+jest.mock('../../../../lib/methods/getThreadName', () => ({
+ __esModule: true,
+ default: jest.fn(() => Promise.resolve('Thread title'))
+}));
+
+const mockGetThreadById = getThreadById as jest.Mock;
+const mockGetThreadName = getThreadName as jest.Mock;
+
+describe('fetchThreadName', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetThreadById.mockResolvedValue(null);
+ });
+
+ it('reports a removed thread even when the caller already has a name', async () => {
+ mockGetThreadById.mockResolvedValue({ t: 'rm' });
+
+ await expect(fetchThreadName('rid-1', 'tmid-1', 'msg-1', 'known name')).resolves.toBe('message removed');
+ expect(mockGetThreadName).not.toHaveBeenCalled();
+ });
+
+ it('keeps the name the caller already has', async () => {
+ await expect(fetchThreadName('rid-1', 'tmid-1', 'msg-1', 'known name')).resolves.toBe('known name');
+ expect(mockGetThreadName).not.toHaveBeenCalled();
+ });
+
+ it('resolves the thread name when the caller has none', async () => {
+ await expect(fetchThreadName('rid-1', 'tmid-1', 'msg-1')).resolves.toBe('Thread title');
+ expect(mockGetThreadName).toHaveBeenCalledWith('rid-1', 'tmid-1', 'msg-1');
+ });
+});
diff --git a/app/views/RoomView/services/__tests__/pushThreadRoom.test.ts b/app/views/RoomView/services/__tests__/pushThreadRoom.test.ts
new file mode 100644
index 00000000000..1dd49cd35e9
--- /dev/null
+++ b/app/views/RoomView/services/__tests__/pushThreadRoom.test.ts
@@ -0,0 +1,100 @@
+import { sendLoadingEvent } from '../../../../containers/Loading';
+import { E2E_MESSAGE_TYPE } from '../../../../lib/constants/keys';
+import { SubscriptionType } from '../../../../definitions';
+import { fetchThreadName } from '../fetchThreadName';
+import { pushThreadRoom } from '../pushThreadRoom';
+
+jest.mock('../../../../containers/Loading', () => ({
+ sendLoadingEvent: jest.fn()
+}));
+jest.mock('../fetchThreadName', () => ({
+ fetchThreadName: jest.fn(() => Promise.resolve('Thread title'))
+}));
+
+const mockFetchThreadName = fetchThreadName as jest.Mock;
+const mockSendLoadingEvent = sendLoadingEvent as jest.Mock;
+
+const navigation = { push: jest.fn() } as any;
+const onCancel = jest.fn();
+
+const push = (item: any) => pushThreadRoom({ rid: 'rid-1', item, roomUserId: 'user-1', navigation, onCancel });
+
+describe('pushThreadRoom', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFetchThreadName.mockResolvedValue('Thread title');
+ });
+
+ it('opens the parent thread of a reply, resolving its name', async () => {
+ await push({ id: 'msg-1', tmid: 'tmid-1', tmsg: 'known name' });
+
+ expect(mockFetchThreadName).toHaveBeenCalledWith('rid-1', 'tmid-1', 'msg-1', 'known name');
+ expect(mockSendLoadingEvent).toHaveBeenCalledWith({ visible: true, onCancel: expect.any(Function) });
+ expect(navigation.push).toHaveBeenCalledWith('RoomView', {
+ rid: 'rid-1',
+ tmid: 'tmid-1',
+ name: 'Thread title',
+ t: SubscriptionType.THREAD,
+ roomUserId: 'user-1',
+ jumpToMessageId: 'msg-1'
+ });
+ });
+
+ it('stops and hides the overlay when the thread name cannot be resolved', async () => {
+ mockFetchThreadName.mockResolvedValue(undefined);
+
+ await push({ id: 'msg-1', tmid: 'tmid-1' });
+
+ expect(mockSendLoadingEvent).toHaveBeenLastCalledWith({ visible: false });
+ expect(navigation.push).not.toHaveBeenCalled();
+ });
+
+ it('hides the overlay and rethrows when the thread name lookup fails', async () => {
+ const error = new Error('lookup failed');
+ mockFetchThreadName.mockRejectedValue(error);
+
+ await expect(push({ id: 'msg-1', tmid: 'tmid-1' })).rejects.toThrow(error);
+
+ expect(mockSendLoadingEvent).toHaveBeenLastCalledWith({ visible: false });
+ expect(navigation.push).not.toHaveBeenCalled();
+ });
+
+ it('titles an undecrypted thread as an encrypted message', async () => {
+ await push({ id: 'msg-1', tmid: 'tmid-1', t: E2E_MESSAGE_TYPE, e2e: 'pending' });
+
+ expect(navigation.push).toHaveBeenCalledWith('RoomView', expect.objectContaining({ name: 'Encrypted message' }));
+ });
+
+ it('opens a thread rooted at the message itself when it has no parent', async () => {
+ await push({ id: 'msg-1', msg: 'hello', tlm: undefined });
+
+ expect(navigation.push).toHaveBeenCalledWith('RoomView', {
+ rid: 'rid-1',
+ tmid: 'msg-1',
+ name: 'hello',
+ t: SubscriptionType.THREAD,
+ roomUserId: 'user-1'
+ });
+ });
+
+ it('cancelling during the fetch stops the thread from opening and hides the overlay', async () => {
+ let resolveFetch: (value: string) => void;
+ mockFetchThreadName.mockReturnValue(new Promise(resolve => (resolveFetch = resolve)));
+
+ const pending = push({ id: 'msg-1', tmid: 'tmid-1', tmsg: 'known name' });
+
+ const { onCancel: overlayOnCancel } = mockSendLoadingEvent.mock.calls[0][0];
+ overlayOnCancel();
+ resolveFetch!('Thread title');
+ await pending;
+
+ expect(mockSendLoadingEvent).toHaveBeenLastCalledWith({ visible: false });
+ expect(navigation.push).not.toHaveBeenCalled();
+ });
+
+ it('does nothing without a rid', async () => {
+ await pushThreadRoom({ rid: undefined, item: { tmid: 'tmid-1' }, roomUserId: null, navigation });
+
+ expect(navigation.push).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/views/RoomView/services/__tests__/sendRoomMessage.test.ts b/app/views/RoomView/services/__tests__/sendRoomMessage.test.ts
new file mode 100644
index 00000000000..517d4ec736f
--- /dev/null
+++ b/app/views/RoomView/services/__tests__/sendRoomMessage.test.ts
@@ -0,0 +1,50 @@
+import { Review } from '../../../../lib/methods/helpers/review';
+import { sendMessage } from '../../../../lib/methods/sendMessage';
+import { sendRoomMessage } from '../sendRoomMessage';
+
+jest.mock('../../../../lib/methods/sendMessage', () => ({
+ sendMessage: jest.fn(() => Promise.resolve())
+}));
+jest.mock('../../../../lib/methods/helpers/review', () => ({
+ Review: { pushPositiveEvent: jest.fn() }
+}));
+jest.mock('../../../../lib/methods/helpers/log', () => ({
+ __esModule: true,
+ default: jest.fn(),
+ logEvent: jest.fn(),
+ events: { ROOM_SEND_MESSAGE: 'ROOM_SEND_MESSAGE' }
+}));
+
+const mockSendMessage = sendMessage as jest.Mock;
+
+const user = { id: 'u1', username: 'user', token: 'tok' };
+
+const send = (message?: string) => {
+ const onMessageSent = jest.fn();
+ const resetAction = jest.fn();
+ sendRoomMessage({ rid: 'rid-1', message, tmid: 'thread-1', user, tshow: true, onMessageSent, resetAction });
+ return { onMessageSent, resetAction };
+};
+
+describe('sendRoomMessage', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('sends the message, clears the unread divider and reports a positive review event', async () => {
+ const { onMessageSent, resetAction } = send('hello');
+
+ await Promise.resolve();
+
+ expect(mockSendMessage).toHaveBeenCalledWith('rid-1', 'hello', 'thread-1', user, true);
+ expect(onMessageSent).toHaveBeenCalledTimes(1);
+ expect(Review.pushPositiveEvent).toHaveBeenCalledTimes(1);
+ expect(resetAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('no-ops when the message is undefined', () => {
+ const { onMessageSent, resetAction } = send(undefined);
+
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ expect(onMessageSent).not.toHaveBeenCalled();
+ expect(resetAction).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/views/RoomView/services/anchorResolver.ts b/app/views/RoomView/services/anchorResolver.ts
index f1549dcf039..35c0a11257f 100644
--- a/app/views/RoomView/services/anchorResolver.ts
+++ b/app/views/RoomView/services/anchorResolver.ts
@@ -3,7 +3,7 @@ import { tsToMs } from '../../../lib/dayjs';
import { type AnchorMessage } from '../definitions';
// Pure anchor math for the bounded Message Window — no React, no DB (unit-tested with plain objects).
-// Returns an upper ts bound (ms): null = Live Window, finite = Anchored Window. See docs/ARCHITECTURE.md.
+// Returns an upper ts bound (ms): null = Live Window, finite = Anchored Window.
export const isNewerLoader = (message: AnchorMessage): boolean => message.t === MessageTypeLoad.NEXT_CHUNK;
// Nearest Newer Loader ABOVE the target = upper bracket of its Chunk. null = target absent, or
diff --git a/app/views/RoomView/services/fetchThreadName.ts b/app/views/RoomView/services/fetchThreadName.ts
index 8635198a889..523be247119 100644
--- a/app/views/RoomView/services/fetchThreadName.ts
+++ b/app/views/RoomView/services/fetchThreadName.ts
@@ -2,10 +2,18 @@ import I18n from '../../../i18n';
import { getThreadById } from '../../../lib/database/services/Thread';
import getThreadName from '../../../lib/methods/getThreadName';
-export const fetchThreadName = async (rid: string, threadId: string, messageId: string): Promise => {
+export const fetchThreadName = async (
+ rid: string,
+ threadId: string,
+ messageId: string,
+ knownName?: string
+): Promise => {
const threadRecord = await getThreadById(threadId);
if (threadRecord?.t === 'rm') {
return I18n.t('Message_removed');
}
+ if (knownName) {
+ return knownName;
+ }
return getThreadName(rid, threadId, messageId);
};
diff --git a/app/views/RoomView/services/getLocalAnchor.ts b/app/views/RoomView/services/getLocalAnchor.ts
index 06d8bd806ca..a6604049d99 100644
--- a/app/views/RoomView/services/getLocalAnchor.ts
+++ b/app/views/RoomView/services/getLocalAnchor.ts
@@ -6,23 +6,36 @@ import { tsToMs } from '../../../lib/dayjs';
import { type TAnyMessageModel } from '../../../definitions';
/**
- * ts of the nearest Newer Loader above the target = the upper bracket of its Chunk; null when the
- * cached region runs contiguous to the Live Tail (caller falls back to the target's own ts).
+ * The single Newer Loader above `aboveTs`: `nearest` = lowest ts (the upper bracket of the target's
+ * Chunk), `closestToLiveTail` = highest ts (the boundary loader the rejoin climbs toward).
*/
-const getLocalAnchorTs = async (rid: string, targetTs: Date | number | string): Promise => {
- const targetMs = tsToMs(targetTs);
- const loaders = (await database.active
+export const findNewerLoaderAbove = async (
+ rid: string,
+ aboveTs: Date | number | string,
+ direction: 'nearest' | 'closestToLiveTail'
+): Promise => {
+ const rows = (await database.active
.get('messages')
.query(
Q.where('rid', rid),
Q.where('t', MessageTypeLoad.NEXT_CHUNK),
- Q.where('ts', Q.gt(targetMs)),
- Q.sortBy('ts', Q.asc),
+ Q.where('ts', Q.gt(tsToMs(aboveTs))),
+ Q.sortBy('ts', direction === 'nearest' ? Q.asc : Q.desc),
Q.take(1)
)
.fetch()) as TAnyMessageModel[];
- return loaders.length ? tsToMs(loaders[0].ts) : null;
+ return rows[0] ?? null;
+};
+
+/**
+ * ts of the nearest Newer Loader above the target = the upper bracket of its Chunk; null when the
+ * cached region runs contiguous to the Live Tail (caller falls back to the target's own ts).
+ */
+const getLocalAnchorTs = async (rid: string, targetTs: Date | number | string): Promise => {
+ const loader = await findNewerLoaderAbove(rid, targetTs, 'nearest');
+
+ return loader ? tsToMs(loader.ts) : null;
};
export default getLocalAnchorTs;
diff --git a/app/views/RoomView/services/joinRoom.ts b/app/views/RoomView/services/joinRoom.ts
index 7fe926fd5bd..e4322fc786d 100644
--- a/app/views/RoomView/services/joinRoom.ts
+++ b/app/views/RoomView/services/joinRoom.ts
@@ -5,13 +5,13 @@ import { type IJoinRoomContext, type IRoomViewState } from '../definitions';
export const joinRoomImpl = async (
room: IRoomViewState['room'],
- { serverVersion, requestJoinCode, onJoin }: IJoinRoomContext
+ { requestJoinCode, onJoin }: IJoinRoomContext
): Promise => {
logEvent(events.ROOM_JOIN);
try {
if (room.t === 'l') {
if ('_id' in room) {
- await takeInquiry(room._id, serverVersion as string);
+ await takeInquiry(room._id);
}
onJoin();
return;
diff --git a/app/views/RoomView/services/jumpToMessage.ts b/app/views/RoomView/services/jumpToMessage.ts
index 3ee1d7aec13..5ce7a5abf1d 100644
--- a/app/views/RoomView/services/jumpToMessage.ts
+++ b/app/views/RoomView/services/jumpToMessage.ts
@@ -6,7 +6,7 @@ import { loadSurroundingMessages } from '../../../lib/methods/loadSurroundingMes
import { resolveJumpAnchor } from './resolveJumpAnchor';
import getMessageInfo from './getMessageInfo';
import getLocalAnchorTs from './getLocalAnchor';
-import { type IJumpToMessageArgs, type TGetMessageInfoResult } from '../definitions';
+import { type IJumpToMessageArgs } from '../definitions';
export const jumpToMessage = async ({
messageId,
@@ -17,38 +17,35 @@ export const jumpToMessage = async ({
listContainerRef,
navToRoom,
navToThread,
- cancel
+ cancel,
+ isCancelled
}: IJumpToMessageArgs): Promise => {
- const isTargetOutsideCurrentView = (message: TGetMessageInfoResult) => {
- if (message.tmid && message.tmid === tmid) {
- return false;
- }
- if (!message.tmid && message.rid === rid) {
- return false;
- }
- return true;
- };
-
try {
sendLoadingEvent({ visible: true, onCancel: cancel });
const message = await getMessageInfo(messageId);
+ if (isCancelled()) {
+ return;
+ }
if (!message) {
cancel();
return;
}
- if (isTargetOutsideCurrentView(message)) {
+ const inThisThread = !!message.tmid && message.tmid === tmid;
+ const inThisRoom = !message.tmid && message.rid === rid;
+
+ if (!inThisThread && !inThisRoom) {
if (message.rid !== rid) {
- navToRoom(message);
+ await navToRoom(message);
} else {
- navToThread(message);
+ await navToThread(message);
}
- } else if (!message.tmid && message.rid === rid && t === 'thread' && !message.replies) {
+ } else if (inThisRoom && t === 'thread' && message.id !== tmid) {
/**
* if the user is within a thread and the message that he is trying to jump to, is a message in the main room
*/
- return navToRoom(message);
+ await navToRoom(message);
} else {
/**
* if it's from server, we don't have it saved locally and so we fetch surroundings
@@ -67,12 +64,21 @@ export const jumpToMessage = async ({
inWindow,
{ loadSurroundingMessages, getLocalAnchorTs }
);
+ if (isCancelled()) {
+ return;
+ }
// Synchronization needed for Fabric to work
await new Promise(res => setTimeout(res, 100));
+ if (isCancelled()) {
+ return;
+ }
await listContainerRef.current?.jumpToMessage(message.id, highTs);
sendLoadingEvent({ visible: false });
}
} catch (error: any) {
+ if (isCancelled()) {
+ return;
+ }
if (isFromReply && error.data?.errorType === 'error-not-allowed') {
showErrorAlert(I18n.t('The_room_does_not_exist'), I18n.t('Room_not_found'));
} else {
diff --git a/app/views/RoomView/services/parseRoomRoute.ts b/app/views/RoomView/services/parseRoomRoute.ts
new file mode 100644
index 00000000000..97baacf9d8e
--- /dev/null
+++ b/app/views/RoomView/services/parseRoomRoute.ts
@@ -0,0 +1,14 @@
+import { getUidDirectMessage } from '../../../lib/methods/helpers';
+import { type IRoomScreenInput, type IRoomViewProps, type TRoomRouteParse } from '../definitions';
+
+export const parseRoomRoute = (params: IRoomViewProps['route']['params']): TRoomRouteParse => {
+ if (!params?.rid || !params.t) {
+ return { status: 'invalid' };
+ }
+ const { rid, t, tmid, name, fname, prid, visitor, joinCodeRequired, roomUserId } = params;
+ const initialRoom: IRoomScreenInput['initialRoom'] = { rid, t, name, fname, prid, visitor, joinCodeRequired };
+ return {
+ status: 'valid',
+ input: { rid, t, tmid, name, initialRoom, roomUserId: roomUserId ?? getUidDirectMessage(initialRoom) }
+ };
+};
diff --git a/app/views/RoomView/services/pushThreadRoom.ts b/app/views/RoomView/services/pushThreadRoom.ts
index 5b11e99b068..75742e87337 100644
--- a/app/views/RoomView/services/pushThreadRoom.ts
+++ b/app/views/RoomView/services/pushThreadRoom.ts
@@ -1,8 +1,7 @@
import I18n from '../../../i18n';
-import { getThreadById } from '../../../lib/database/services/Thread';
-import getThreadName from '../../../lib/methods/getThreadName';
import { makeThreadName } from '../../../lib/methods/helpers/room';
import { sendLoadingEvent } from '../../../containers/Loading';
+import { fetchThreadName } from './fetchThreadName';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../../lib/constants/keys';
import { SubscriptionType, type TAnyMessageModel } from '../../../definitions';
import { type IRoomViewProps, type TGetMessageInfoResult } from '../definitions';
@@ -15,13 +14,7 @@ interface IPushThreadRoomParams {
onCancel?: () => void;
}
-export const pushThreadRoom = async ({
- rid,
- item,
- roomUserId,
- navigation,
- onCancel
-}: IPushThreadRoomParams): Promise => {
+export const pushThreadRoom = async ({ rid, item, roomUserId, navigation, onCancel }: IPushThreadRoomParams): Promise => {
if (!rid) {
return;
}
@@ -33,19 +26,26 @@ export const pushThreadRoom = async ({
name = 'tmsg' in item ? (item.tmsg ?? '') : '';
jumpToMessageId = item.id;
}
- sendLoadingEvent({ visible: true, onCancel });
- const threadRecord = await getThreadById(item.tmid);
- if (threadRecord?.t === 'rm') {
- name = I18n.t('Thread');
- }
- if (!name) {
- const threadName = await getThreadName(rid, item.tmid, jumpToMessageId);
- if (!threadName) {
+ let cancelled = false;
+ sendLoadingEvent({
+ visible: true,
+ onCancel: () => {
+ cancelled = true;
+ onCancel?.();
+ }
+ });
+ let threadName: string | undefined;
+ try {
+ threadName = await fetchThreadName(rid, item.tmid, jumpToMessageId, name);
+ } finally {
+ if (!threadName || cancelled) {
sendLoadingEvent({ visible: false });
- return;
}
- name = threadName;
}
+ if (!threadName || cancelled) {
+ return;
+ }
+ name = threadName;
if ('id' in item && 't' in item && item.t === E2E_MESSAGE_TYPE && 'e2e' in item && item.e2e !== E2E_STATUS.DONE) {
name = I18n.t('Encrypted_message');
}
diff --git a/app/views/RoomView/stores/ComposerStore.tsx b/app/views/RoomView/stores/ComposerStore.tsx
index 034b72a2075..a2213744f3b 100644
--- a/app/views/RoomView/stores/ComposerStore.tsx
+++ b/app/views/RoomView/stores/ComposerStore.tsx
@@ -17,7 +17,7 @@ export const createComposerStore = (initial: TComposerExternalState) =>
export const ComposerStoreContext = createContext(null);
-const useComposerStoreApi = (): ComposerStore => {
+export const useComposerStoreApi = (): ComposerStore => {
const store = useContext(ComposerStoreContext);
if (!store) {
throw new Error('Composer store hooks must be used within a ComposerProvider');
diff --git a/app/views/RoomView/stores/RoomStore.test.ts b/app/views/RoomView/stores/RoomStore.test.ts
index 342bdc953c4..483e9dfc4fd 100644
--- a/app/views/RoomView/stores/RoomStore.test.ts
+++ b/app/views/RoomView/stores/RoomStore.test.ts
@@ -1,3 +1,4 @@
+import { StrictMode } from 'react';
import { InteractionManager } from 'react-native';
import { renderHook } from '@testing-library/react-native';
@@ -112,7 +113,7 @@ describe('RoomStore', () => {
it('flips to preview mode (not subscribed, not joined) when a non-DM has no subscription', () => {
const { emit } = setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: stubRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
emit([]);
@@ -123,7 +124,7 @@ describe('RoomStore', () => {
it('keeps a DM joined even with no subscription yet', () => {
const { emit } = setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'd', initialRoom: stubRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: { ...stubRoom, t: 'd' } });
emit([]);
@@ -133,7 +134,7 @@ describe('RoomStore', () => {
it('flips joined back to true once the subscription appears later', () => {
const { emit } = setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: stubRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
emit([]);
expect(store.getState().joined).toBe(false);
@@ -146,7 +147,7 @@ describe('RoomStore', () => {
it('rebuilds a fresh roomUpdate snapshot when the same model instance re-emits a mutated column', () => {
const { emit } = setupObserve();
const mutable = { ...subRoom, topic: 'old' };
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: stubRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
emit([mutable]);
const first = store.getState().roomUpdate;
@@ -161,16 +162,72 @@ describe('RoomStore', () => {
expect(store.getState().roomUpdate).not.toBe(first);
});
- it('observes with exactly the roomAttrsUpdateColumns values', () => {
- const { observeWithColumns } = setupObserve();
- peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
+ it('keeps room pointing at the live model instance when only lastMessage changes on a Livechat row', () => {
+ const { emit } = setupObserve();
+ const mutable: Record = {
+ id: 'sub-1',
+ rid: 'rid-1',
+ t: 'l',
+ lastMessage: { u: { _id: 'visitor-1' }, token: 'v' }
+ };
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
+
+ emit([mutable]);
+ expect(store.getState().lastMessageFromAgent).toBe(false);
+
+ mutable.lastMessage = { u: { _id: 'agent-1' } };
+ emit([mutable]);
- expect(observeWithColumns).toHaveBeenCalledWith(Object.values(roomAttrsUpdateColumns));
+ expect(store.getState().lastMessageFromAgent).toBe(true);
+ expect(store.getState().room).toBe(mutable);
+ });
+
+ it('replaces room when the subscription row is recreated with identical attributes', () => {
+ const { emit } = setupObserve();
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
+
+ emit([subRoom]);
+ emit([]);
+ const recreated = { ...subRoom, id: 'sub-2' };
+ emit([recreated]);
+
+ expect(store.getState().subscribed).toBe(true);
+ expect(store.getState().room).toBe(recreated);
+ });
+
+ it('derives the agent-authored flag from a Livechat row', () => {
+ const { emit } = setupObserve();
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
+
+ emit([{ id: 'sub-1', rid: 'rid-1', t: 'l', lastMessage: { u: { _id: 'agent-1' } } }]);
+
+ expect(store.getState().lastMessageFromAgent).toBe(true);
+ });
+
+ it('does not update the agent-authored flag for a Channel last Message', () => {
+ const { emit } = setupObserve();
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
+
+ emit([{ ...subRoom, lastMessage: { u: { _id: 'agent-1' } } }]);
+
+ expect(store.getState().lastMessageFromAgent).toBe(false);
+ });
+
+ it('clears the agent-authored flag when the row stops being a Livechat room', () => {
+ const { emit } = setupObserve();
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
+
+ emit([{ id: 'sub-1', rid: 'rid-1', t: 'l', lastMessage: { u: { _id: 'agent-1' } } }]);
+ expect(store.getState().lastMessageFromAgent).toBe(true);
+
+ emit([{ id: 'sub-1', rid: 'rid-1', t: 'c', lastMessage: { u: { _id: 'agent-1' } } }]);
+
+ expect(store.getState().lastMessageFromAgent).toBe(false);
});
it('runs the main init path: fetches messages and sets member and canAutoTranslate', async () => {
setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
await store.getState().init();
@@ -181,7 +238,7 @@ describe('RoomStore', () => {
it('loads messages without a read receipt for a route-param room that lacks a subscription row', async () => {
setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: stubRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
await store.getState().init();
@@ -191,7 +248,7 @@ describe('RoomStore', () => {
it('routes a cursor-less subscribed room to the room-history loader directly', async () => {
setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
await store.getState().init();
@@ -203,7 +260,7 @@ describe('RoomStore', () => {
it('routes a subscribed room with a cursor to the missed-messages loader', async () => {
setupObserve();
const roomWithCursor = { ...subRoom, lastOpen: new Date('2026-01-01T00:00:00.000Z') };
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: roomWithCursor });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: roomWithCursor });
await store.getState().init();
@@ -226,7 +283,7 @@ describe('RoomStore', () => {
it('early-returns without fetching messages when the room is an invite subscription', async () => {
setupObserve();
mockIsInviteSubscription.mockReturnValue(true);
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
await expect(store.getState().init()).resolves.toEqual({ status: 'skipped' });
@@ -237,7 +294,7 @@ describe('RoomStore', () => {
setupObserve();
mockGetUserInfo.mockResolvedValue({ success: true, user: { _id: 'uid-1', username: 'alice' } });
const dmRoom = { ...subRoom, t: 'd' };
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'd', initialRoom: dmRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: dmRoom });
await store.getState().init();
@@ -255,7 +312,7 @@ describe('RoomStore', () => {
})
);
const dmRoom = { ...subRoom, t: 'd' };
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'd', initialRoom: dmRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: dmRoom });
const initPromise = store.getState().init();
await Promise.resolve();
@@ -277,7 +334,7 @@ describe('RoomStore', () => {
controller.abort();
return Promise.resolve();
});
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
await expect(store.getState().init({ signal: controller.signal })).resolves.toEqual({ status: 'skipped' });
@@ -298,7 +355,7 @@ describe('RoomStore', () => {
setupObserve();
const error = new Error('boom');
mockGetMessages.mockRejectedValueOnce(error);
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const initPromise = store.getState().init();
await jest.advanceTimersByTimeAsync(1000);
@@ -311,7 +368,7 @@ describe('RoomStore', () => {
setupObserve();
const unreadRoom = { ...subRoom, alert: true, ls: new Date('2026-01-01T00:00:00.000Z') };
mockGetMessages.mockRejectedValueOnce(new Error('boom'));
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: unreadRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: unreadRoom });
const initPromise = store.getState().init();
await jest.advanceTimersByTimeAsync(1000);
@@ -323,7 +380,7 @@ describe('RoomStore', () => {
it('gives up after three attempts and resolves as failed', async () => {
setupObserve();
mockGetMessages.mockRejectedValue(new Error('boom'));
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const initPromise = store.getState().init();
await jest.advanceTimersByTimeAsync(10000);
@@ -335,7 +392,7 @@ describe('RoomStore', () => {
it('retries against the room the observer delivered after the first attempt failed on an empty store', async () => {
const { emit } = setupObserve();
mockGetMessages.mockRejectedValueOnce(new Error('boom'));
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: { rid: '', t: '' } });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: { rid: '', t: '' } });
const initPromise = store.getState().init();
await jest.advanceTimersByTimeAsync(0);
@@ -352,7 +409,7 @@ describe('RoomStore', () => {
const { emit } = setupObserve();
const unreadRoom = { ...subRoom, alert: true, ls: new Date('2026-02-02T00:00:00.000Z') };
mockGetMessages.mockRejectedValueOnce(new Error('boom'));
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const initPromise = store.getState().init();
await jest.advanceTimersByTimeAsync(0);
@@ -365,7 +422,7 @@ describe('RoomStore', () => {
it('does not retry an invite subscription', async () => {
setupObserve();
mockIsInviteSubscription.mockReturnValue(true);
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const initPromise = store.getState().init();
await jest.advanceTimersByTimeAsync(10000);
@@ -378,7 +435,7 @@ describe('RoomStore', () => {
setupObserve();
mockGetMessages.mockRejectedValue(new Error('boom'));
const controller = new AbortController();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const initPromise = store.getState().init({ signal: controller.signal });
await jest.advanceTimersByTimeAsync(0);
@@ -400,7 +457,7 @@ describe('RoomStore', () => {
it('join() sets joined true', () => {
const { emit } = setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: stubRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: stubRoom });
emit([]);
expect(store.getState().joined).toBe(false);
@@ -468,7 +525,7 @@ describe('RoomStore', () => {
return { then: () => {} };
}) as unknown as typeof InteractionManager.runAfterInteractions);
- const { result } = renderHook(() => useRoomStoreForScreen({ rid: 'rid-1', t: 'c', initialRoom: stubRoom }));
+ const { result } = renderHook(() => useRoomStoreForScreen({ rid: 'rid-1', initialRoom: stubRoom }));
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(observeWithColumns).toHaveBeenCalledTimes(2);
@@ -478,7 +535,7 @@ describe('RoomStore', () => {
it('owns the store until the screen transition finishes', () => {
const { unsubscribe } = setupObserve();
- const { result, unmount } = renderHook(() => useRoomStoreForScreen({ rid: 'rid-1', t: 'c', initialRoom: stubRoom }));
+ const { result, unmount } = renderHook(() => useRoomStoreForScreen({ rid: 'rid-1', initialRoom: stubRoom }));
flushSweeps();
expect(result.current.getState().room).toBe(stubRoom);
@@ -491,6 +548,19 @@ describe('RoomStore', () => {
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
+ it('releases every reference under StrictMode so the observer is torn down after unmount', () => {
+ const { unsubscribe } = setupObserve();
+ const { unmount } = renderHook(() => useRoomStoreForScreen({ rid: 'rid-1', initialRoom: stubRoom }), {
+ wrapper: StrictMode
+ });
+
+ flushSweeps();
+ unmount();
+ flushSweeps();
+
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ });
+
it('warm-up then navigate: peek keeps the store alive across the sweep once the mount acquires it', () => {
const { unsubscribe } = setupObserve();
diff --git a/app/views/RoomView/stores/RoomStore.ts b/app/views/RoomView/stores/RoomStore.ts
index 1c2d9d94af9..d035b9f1950 100644
--- a/app/views/RoomView/stores/RoomStore.ts
+++ b/app/views/RoomView/stores/RoomStore.ts
@@ -21,7 +21,6 @@ import {
import { roomAttrsUpdate, roomAttrsUpdateColumns } from '../constants';
import getMessages from '../services/getMessages';
import { joinRoomImpl, resumeRoomImpl } from '../services/joinRoom';
-import { store as reduxStore } from '../../../lib/store/auxStore';
const OBSERVED_COLUMNS = Object.values(roomAttrsUpdateColumns);
@@ -135,9 +134,8 @@ const createRoomState =
roomUserId,
canAutoTranslate: false,
canForwardGuest: false,
- canReturnQueue: false,
canViewCannedResponse: false,
- canPlaceLivechatOnHold: false,
+ lastMessageFromAgent: false,
// A transient failure retries a couple of times instead of leaving the screen empty until the
// user navigates away and back. The loop lives here so the caller's single `loading` window
@@ -190,35 +188,50 @@ const createRoomState =
// store, so the caller passes its own trigger instead of registering one here.
joinRoom: requestJoinCode =>
joinRoomImpl(get().room, {
- serverVersion: reduxStore.getState().server.version,
requestJoinCode,
onJoin: get().join
}),
resumeRoom: () => resumeRoomImpl(get().room, { onJoin: get().join })
});
-const observeRoom = (rid: string | undefined, t: string | undefined, store: RoomStore): (() => void) => {
+const observeRoom = (rid: string | undefined, initialRoom: IRoomViewState['room'], store: RoomStore): (() => void) => {
if (!rid) {
return () => {};
}
- const observable = database.active.get('subscriptions').query(Q.where('rid', rid)).observeWithColumns(OBSERVED_COLUMNS);
+ let lastRoomType = initialRoom.t;
+ let lastMessageFromAgent = store.getState().lastMessageFromAgent;
+ const observable = database.active
+ .get('subscriptions')
+ .query(Q.where('rid', rid))
+ .observeWithColumns([...OBSERVED_COLUMNS, 'last_message']);
const subscription = observable.subscribe((rows: IRoomViewState['room'][]) => {
const next = rows[0];
if (next) {
- store.setState({
- room: next,
- // observeWithColumns re-emits the same cached model instance mutated in place, so a fresh
- // snapshot object is what re-renders consumers on a tracked-column change.
- roomUpdate: roomAttrsUpdate.reduce((ret: IRoomViewState['roomUpdate'], attr) => {
- ret[attr] = (next as TSubscriptionModel)[attr];
- return ret;
- }, {}),
- subscribed: true,
- joined: true
- });
+ lastRoomType = next.t;
+ const nextLastMessageFromAgent = next.t === 'l' && !!(next.lastMessage && !next.lastMessage.token && next.lastMessage.u);
+ const roomUpdate = Object.fromEntries(
+ roomAttrsUpdate.map(attr => [attr, (next as TSubscriptionModel)[attr]])
+ ) as IRoomViewState['roomUpdate'];
+ const { room: previousRoom, roomUpdate: previousRoomUpdate } = store.getState();
+ const rowRecreated = next !== previousRoom;
+ const roomChanged = rowRecreated || roomAttrsUpdate.some(attr => previousRoomUpdate[attr] !== roomUpdate[attr]);
+ const state = roomChanged
+ ? {
+ room: next,
+ roomUpdate,
+ subscribed: true,
+ joined: true
+ }
+ : { subscribed: true, joined: true };
+ if (nextLastMessageFromAgent !== lastMessageFromAgent) {
+ lastMessageFromAgent = nextLastMessageFromAgent;
+ store.setState({ ...state, lastMessageFromAgent });
+ } else if (roomChanged || !store.getState().subscribed) {
+ store.setState(state);
+ }
return;
}
- store.setState({ subscribed: false, ...(t !== 'd' ? { joined: false } : {}) });
+ store.setState({ subscribed: false, ...(lastRoomType !== 'd' ? { joined: false } : {}) });
});
return () => subscription.unsubscribe();
};
@@ -254,14 +267,15 @@ const scheduleGraceSweep = (rid: string): void => {
});
};
-const register = (rid: string, t: string | undefined, store: RoomStore, refCount: number): void => {
- registry.set(rid, { store, unsubscribe: observeRoom(rid, t, store), refCount, pendingSweep: false });
+const register = (rid: string, initialRoom: IRoomViewState['room'], store: RoomStore, refCount: number): void => {
+ const unsubscribe = observeRoom(rid, initialRoom, store);
+ registry.set(rid, { store, unsubscribe, refCount, pendingSweep: false });
};
// Render-safe: returns the rid-keyed store, creating it (observer + grace sweep) on first sight
// without touching refCount. Safe to call from a useState initializer, which may run twice under
// StrictMode/concurrent render. Acquire/release own the lifetime.
-export const peekOrCreateRoomStore = ({ rid, t, initialRoom, roomUserId }: IGetOrCreateRoomStoreParams): RoomStore => {
+export const peekOrCreateRoomStore = ({ rid, initialRoom, roomUserId }: IGetOrCreateRoomStoreParams): RoomStore => {
if (!rid) {
return createStore(createRoomState(rid, initialRoom, roomUserId));
}
@@ -270,7 +284,7 @@ export const peekOrCreateRoomStore = ({ rid, t, initialRoom, roomUserId }: IGetO
return existing.store;
}
const store = createStore(createRoomState(rid, initialRoom, roomUserId));
- register(rid, t, store, 0);
+ register(rid, initialRoom, store, 0);
scheduleGraceSweep(rid);
return store;
};
@@ -292,7 +306,7 @@ export const releaseRoomStore = (rid?: string): void => {
// Claims ownership of the rid-keyed store for one screen: increments refCount, re-registering the
// observer if the grace sweep reclaimed the entry between render and effect.
-export const acquireRoomStore = ({ rid, t }: Pick, store: RoomStore): RoomStore => {
+export const acquireRoomStore = ({ rid }: Pick, store: RoomStore): RoomStore => {
if (!rid) {
return store;
}
@@ -301,21 +315,22 @@ export const acquireRoomStore = ({ rid, t }: Pick {
const [screenParams] = useState(params);
- const [store, setStore] = useState(() => peekOrCreateRoomStore(screenParams));
+ const [peekedStore] = useState(() => peekOrCreateRoomStore(screenParams));
+ const [store, setStore] = useState(peekedStore);
const { rid } = screenParams;
useEffect(() => {
- setStore(acquireRoomStore(screenParams, store));
+ setStore(acquireRoomStore(screenParams, peekedStore));
return () => {
InteractionManager.runAfterInteractions(() => releaseRoomStore(rid));
};
- }, [rid, screenParams, store]);
+ }, [rid, screenParams, peekedStore]);
return store;
};
diff --git a/app/views/RoomView/stores/RoomStoreContext.test.tsx b/app/views/RoomView/stores/RoomStoreContext.test.tsx
index 4ffab2e23d0..bc4b10c01f8 100644
--- a/app/views/RoomView/stores/RoomStoreContext.test.tsx
+++ b/app/views/RoomView/stores/RoomStoreContext.test.tsx
@@ -61,7 +61,7 @@ describe('useRoomWithUpdate', () => {
it('re-renders with the fresh field when the same room instance re-emits a mutated tracked column', () => {
const { emit } = setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const spy = jest.fn();
const Probe = () => {
@@ -90,7 +90,7 @@ describe('useRoomWithUpdate', () => {
it('does NOT re-render a plain `s.room` selector on the same mutated-in-place emit (documents why the hook exists)', () => {
const { emit } = setupObserve();
- const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom });
+ const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: subRoom });
const spy = jest.fn();
const PlainProbe = () => {
diff --git a/app/views/RoomView/stores/joinRoom.test.ts b/app/views/RoomView/stores/joinRoom.test.ts
index 5a699dc7cb2..73c2c376895 100644
--- a/app/views/RoomView/stores/joinRoom.test.ts
+++ b/app/views/RoomView/stores/joinRoom.test.ts
@@ -1,11 +1,24 @@
import { joinRoom as joinRoomService } from '../../../lib/services/restApi';
import { takeInquiry, takeResume } from '../../../ee/omnichannel/lib';
import { type IRoomViewState } from '../definitions';
-import { peekOrCreateRoomStore } from './RoomStore';
+import { peekOrCreateRoomStore, releaseRoomStore } from './RoomStore';
jest.mock('../../../lib/database', () => ({
__esModule: true,
- default: { active: { get: jest.fn() } }
+ default: {
+ active: {
+ get: () => ({
+ query: () => ({
+ observeWithColumns: () => ({
+ subscribe: (next: (rows: unknown[]) => void) => {
+ next([]);
+ return { unsubscribe: jest.fn() };
+ }
+ })
+ })
+ })
+ }
+ }
}));
jest.mock('../services/getMessages', () => ({ __esModule: true, default: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../lib/methods/loadThreadMessages', () => ({ loadThreadMessages: jest.fn(() => Promise.resolve()) }));
@@ -27,7 +40,6 @@ jest.mock('../../../ee/omnichannel/lib', () => ({
takeInquiry: jest.fn(() => Promise.resolve()),
takeResume: jest.fn(() => Promise.resolve())
}));
-jest.mock('../../../lib/store/auxStore', () => ({ store: { getState: () => ({ server: { version: '6.1.0' } }) } }));
const mockJoinRoomService = joinRoomService as jest.Mock;
const mockTakeInquiry = takeInquiry as jest.Mock;
@@ -63,15 +75,27 @@ describe('RoomStore join/resume actions', () => {
expect(store.getState().join).not.toHaveBeenCalled();
});
- it('joinRoom omnichannel path calls takeInquiry with the room id and server version, then joins', async () => {
+ it('joinRoom omnichannel path calls takeInquiry with the room id, then joins', async () => {
const store = makeStore({ _id: 'room-id-1', rid: 'rid-1', t: 'l' } as any);
await store.getState().joinRoom();
- expect(mockTakeInquiry).toHaveBeenCalledWith('room-id-1', '6.1.0');
+ expect(mockTakeInquiry).toHaveBeenCalledWith('room-id-1');
expect(store.getState().join).toHaveBeenCalledTimes(1);
});
+ it('joinRoom omnichannel path on a warmed rid-keyed store passes no server version', async () => {
+ const room = { _id: 'room-id-2', rid: 'warm-rid', t: 'l' } as any;
+ const warmed = peekOrCreateRoomStore({ rid: 'warm-rid', initialRoom: room });
+ warmed.setState({ room, join: jest.fn() });
+
+ await warmed.getState().joinRoom();
+
+ expect(mockTakeInquiry).toHaveBeenCalledWith('room-id-2');
+ expect(warmed.getState().join).toHaveBeenCalledTimes(1);
+ releaseRoomStore('warm-rid');
+ });
+
it('joinRoom plain path calls the join service then joins the store', async () => {
const store = makeStore({ rid: 'rid-1', t: 'c' });