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 */} -