From 2bb98fe586099470086c7c1576eaba52135877df Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 14:55:16 -0300 Subject: [PATCH 1/6] refactor: give shared composer ownership of input and configuration --- .../MessageComposer}/ComposerStore.test.tsx | 12 +- .../MessageComposer}/ComposerStore.tsx | 47 +- .../MessageComposer/MessageComposer.test.tsx | 247 +++++++++- .../MessageComposer/MessageComposer.tsx | 14 +- .../components/Autocomplete/Autocomplete.tsx | 2 +- .../useAutocompleteA11yAnnounce.test.tsx | 8 +- .../useAutocompleteA11yAnnounce.ts | 2 +- .../components/Buttons/ActionsButton.tsx | 2 +- .../components/Buttons/MicOrSendButton.tsx | 2 +- .../MessageComposer/components/CancelEdit.tsx | 2 +- .../components/ComposerInput.test.tsx | 192 ++++++++ .../components/ComposerInput.tsx | 16 +- .../components/Quotes/Quote.tsx | 2 +- .../components/RecordAudio/RecordAudio.tsx | 2 +- .../components/SendThreadToChannel.tsx | 2 +- .../components/Toolbar/Default.tsx | 2 +- .../components/Unfocused/Left.tsx | 2 +- app/containers/MessageComposer/context.tsx | 4 + .../MessageComposer/hooks/useAutoSaveDraft.ts | 2 +- .../hooks/useChooseMedia.test.tsx | 15 +- .../MessageComposer/hooks/useChooseMedia.ts | 17 +- .../hooks/useEmojiKeyboard.test.tsx | 8 +- app/containers/MessageComposer/index.tsx | 1 + app/views/RoomView/List/components/List.tsx | 2 +- app/views/RoomView/RoomScreen.tsx | 8 +- .../__tests__/roomAndThreadScreens.test.tsx | 2 +- .../components/RoomProviders.test.tsx | 41 +- .../RoomView/components/RoomProviders.tsx | 11 +- app/views/RoomView/definitions.ts | 23 - .../__tests__/useMessageActions.test.tsx | 30 +- .../hooks/__tests__/useRoomMessaging.test.tsx | 155 +++++++ .../RoomView/hooks/useMessageActions.tsx | 11 +- app/views/RoomView/hooks/useRoomMessaging.ts | 8 +- .../RoomView/reactCompilerContract.test.ts | 2 + app/views/ShareView/ShareView.test.tsx | 426 +++++++++++++++++- app/views/ShareView/index.tsx | 54 ++- 36 files changed, 1173 insertions(+), 203 deletions(-) rename app/{views/RoomView/stores/__tests__ => containers/MessageComposer}/ComposerStore.test.tsx (93%) rename app/{views/RoomView/stores => containers/MessageComposer}/ComposerStore.tsx (67%) create mode 100644 app/containers/MessageComposer/components/ComposerInput.test.tsx create mode 100644 app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx diff --git a/app/views/RoomView/stores/__tests__/ComposerStore.test.tsx b/app/containers/MessageComposer/ComposerStore.test.tsx similarity index 93% rename from app/views/RoomView/stores/__tests__/ComposerStore.test.tsx rename to app/containers/MessageComposer/ComposerStore.test.tsx index 657b7572461..beb25fc5e5c 100644 --- a/app/views/RoomView/stores/__tests__/ComposerStore.test.tsx +++ b/app/containers/MessageComposer/ComposerStore.test.tsx @@ -1,7 +1,7 @@ import { useContext, type ReactNode } from 'react'; import { act, render, renderHook } from '@testing-library/react-native'; -import { type ComposerState } from '../../definitions'; +import { type ComposerState } from './ComposerStore'; import { ComposerProvider, ComposerStoreContext, @@ -15,10 +15,8 @@ import { useEditRequest, useOnRemoveQuoteMessage, useOnSendMessage, - useSetQuotesAndText, - useGetText, useUpdateAutocompleteVisible -} from '../ComposerStore'; +} from './ComposerStore'; const room = { rid: 'rid-1', t: 'c' }; @@ -31,9 +29,7 @@ const fullProps = () => ({ editCancel: jest.fn(), editRequest: jest.fn(() => Promise.resolve()), onRemoveQuoteMessage: jest.fn(), - onSendMessage: jest.fn(), - setQuotesAndText: jest.fn(), - getText: jest.fn(() => 'text') + onSendMessage: jest.fn() }); const useAllComposerHooks = () => ({ @@ -47,8 +43,6 @@ const useAllComposerHooks = () => ({ editRequest: useEditRequest(), onRemoveQuoteMessage: useOnRemoveQuoteMessage(), onSendMessage: useOnSendMessage(), - setQuotesAndText: useSetQuotesAndText(), - getText: useGetText(), updateAutocompleteVisible: useUpdateAutocompleteVisible() }); diff --git a/app/views/RoomView/stores/ComposerStore.tsx b/app/containers/MessageComposer/ComposerStore.tsx similarity index 67% rename from app/views/RoomView/stores/ComposerStore.tsx rename to app/containers/MessageComposer/ComposerStore.tsx index 2c5ef545881..981f2b9f252 100644 --- a/app/views/RoomView/stores/ComposerStore.tsx +++ b/app/containers/MessageComposer/ComposerStore.tsx @@ -1,8 +1,49 @@ import { createContext, useContext, useEffect, useState, type ReactElement, type ReactNode } from 'react'; import { createStore, useStore } from 'zustand'; +import { type StoreApi } from 'zustand'; -import { type ComposerState, type ComposerStore, type TComposerExternalState } from '../definitions'; -import { useRoomWithUpdateFromStore } from './RoomStoreContext'; +import { + type IMessage, + type IMessageEditAttachment, + type ILastMessage, + type IVisitor, + type TSubscriptionModel +} from '../../definitions'; +import { useRoomWithUpdateFromStore } from '../../views/RoomView/stores/RoomStoreContext'; + +type ComposerRoom = + | TSubscriptionModel + | { + rid: string; + t: string; + name?: string; + fname?: string; + prid?: string; + visitor?: IVisitor; + joinCodeRequired?: boolean; + status?: string; + lastMessage?: ILastMessage; + sysMes?: boolean; + onHold?: boolean; + }; + +export type ComposerState = { + rid?: string; + t?: string; + tmid?: string; + room: ComposerRoom; + roomUpdate?: Partial; + sharing?: boolean; + isAutocompleteVisible: boolean; + editCancel?: () => void; + editRequest?: (message: Pick & { attachments?: IMessageEditAttachment[] }) => Promise; + onRemoveQuoteMessage?: (messageId: string) => void; + onSendMessage?: (message?: string, tshow?: boolean) => void; + updateAutocompleteVisible: (updatedAutocompleteVisible: boolean) => void; +}; + +export type TComposerExternalState = Omit; +export type ComposerStore = StoreApi; export const createComposerStore = (initial: TComposerExternalState) => createStore()(set => ({ @@ -50,7 +91,5 @@ export const useEditCancel = (): ComposerState['editCancel'] => useComposerStore export const useEditRequest = (): ComposerState['editRequest'] => useComposerStore(s => s.editRequest); export const useOnRemoveQuoteMessage = (): ComposerState['onRemoveQuoteMessage'] => useComposerStore(s => s.onRemoveQuoteMessage); export const useOnSendMessage = (): ComposerState['onSendMessage'] => useComposerStore(s => s.onSendMessage); -export const useSetQuotesAndText = (): ComposerState['setQuotesAndText'] => useComposerStore(s => s.setQuotesAndText); -export const useGetText = (): ComposerState['getText'] => useComposerStore(s => s.getText); export const useUpdateAutocompleteVisible = (): ComposerState['updateAutocompleteVisible'] => useComposerStore(s => s.updateAutocompleteVisible); diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index b92bb035192..168d9abe5c5 100644 --- a/app/containers/MessageComposer/MessageComposer.test.tsx +++ b/app/containers/MessageComposer/MessageComposer.test.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactElement } from 'react'; +import { useEffect, type ReactElement, type RefObject } from 'react'; import { act, render, screen, fireEvent, waitFor, userEvent } from '@testing-library/react-native'; import { Provider } from 'react-redux'; @@ -12,16 +12,48 @@ import { mockedStore } from '../../reducers/mockedStore'; import { type IPermissionsState } from '../../reducers/permissions'; import { type IMessage, type IShareAttachment, type TMessageActionState } from '../../definitions'; import { colors } from '../../lib/constants/colors'; -import { type ComposerState } from '../../views/RoomView/definitions'; -import { ComposerProvider } from '../../views/RoomView/stores/ComposerStore'; +import { type ComposerState } from './ComposerStore'; +import { ComposerProvider } from './ComposerStore'; import { MessageActionProvider } from '../message/stores/MessageActionStore'; import * as EmojiKeyboardHook from './hooks/useEmojiKeyboard'; import { initStore } from '../../lib/store/auxStore'; import { searchRemote } from '../../lib/methods/search'; import database from '../../lib/database'; import { useMessageComposerApi } from './context'; +import { type IMessageComposerRef } from './interfaces'; import { sendFileMessage } from '../../lib/methods/sendFileMessage'; import { runSlashCommand } from '../../lib/services/restApi'; +import { useChooseMedia } from './hooks/useChooseMedia'; +import { useMessageActionStoreApi } from '../message/stores/MessageActionStore'; +import { useAltTextSupported } from '../../lib/hooks/useAltTextSupported'; + +jest.mock('expo-document-picker', () => ({ + getDocumentAsync: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/ImagePicker/ImagePicker', () => ({ + __esModule: true, + default: { + openCamera: jest.fn(), + openPicker: jest.fn() + } +})); + +jest.mock('../../lib/database/services/Subscription', () => ({ + getSubscriptionByRoomId: jest.fn() +})); + +jest.mock('../../lib/database/services/Thread', () => ({ + getThreadById: jest.fn() +})); + +jest.mock('../../lib/navigation/appNavigation', () => ({ + navigate: jest.fn() +})); + +jest.mock('../../lib/hooks/useAltTextSupported', () => ({ + useAltTextSupported: jest.fn() +})); jest.useFakeTimers(); @@ -121,16 +153,18 @@ const initialContext = { const Render = ({ context, action, - children + children, + forwardedRef }: { context?: Partial; action?: TMessageActionState; children?: ReactElement; + forwardedRef?: RefObject; }) => ( - + <> {children} @@ -141,6 +175,29 @@ const Render = ({ ); +type MediaTransferProbe = ReturnType; +let mediaTransferProbe: MediaTransferProbe; +let mediaActionStore: ReturnType; +const mediaTransferProbes: Record = {}; +const mediaActionStores: Record> = {}; + +const MediaTransferProbe = ({ + name = 'default', + rid = 'rid', + tmid = 'thread-id' +}: { + name?: string; + rid?: string; + tmid?: string; +}) => { + const probe = useChooseMedia({ rid, tmid, permissionToUpload: true }); + mediaTransferProbe = probe; + mediaTransferProbes[name] = probe; + mediaActionStore = useMessageActionStoreApi(); + mediaActionStores[name] = mediaActionStore; + return null; +}; + const AttachmentSeeder = ({ attachments }: { attachments: IShareAttachment[] }) => { const { addAttachments } = useMessageComposerApi(); @@ -210,6 +267,23 @@ beforeEach(() => { sharedValue.value = false; // reset before each test sharedValueSearchbar.value = false; keyboardHeightSharedValue.value = 0; + mediaTransferProbe = undefined as unknown as MediaTransferProbe; + mediaActionStore = undefined as unknown as ReturnType; + Object.keys(mediaTransferProbes).forEach(key => delete mediaTransferProbes[key]); + Object.keys(mediaActionStores).forEach(key => delete mediaActionStores[key]); + (useAltTextSupported as jest.Mock).mockReturnValue(false); + (require('expo-document-picker').getDocumentAsync as jest.Mock).mockReset(); + const imagePicker = require('../../lib/methods/helpers/ImagePicker/ImagePicker').default; + imagePicker.openCamera.mockReset(); + imagePicker.openPicker.mockReset(); + require('../../lib/database/services/Subscription').getSubscriptionByRoomId.mockResolvedValue({ + rid: 'rid', + t: 'c', + roles: [], + observe: () => ({ subscribe: () => ({ unsubscribe: jest.fn() }) }) + }); + require('../../lib/database/services/Thread').getThreadById.mockResolvedValue({ id: 'thread-id' }); + require('../../lib/navigation/appNavigation').navigate.mockClear(); }); describe('MessageComposer', () => { @@ -807,5 +881,168 @@ describe('MessageComposer', () => { expect(onSendMessage).not.toHaveBeenCalled(); expect(screen.queryByTestId('message-composer-attachments')).not.toBeOnTheScreen(); }); + + test('clears input after a delayed successful upload, including text typed while uploading', async () => { + let resolveUpload!: () => void; + const composerRef = { current: null } as RefObject; + (sendFileMessage as jest.Mock).mockImplementationOnce(() => new Promise(resolve => (resolveUpload = resolve))); + render( + + + + ); + await screen.findByTestId('message-composer-attachment-0'); + await screen.findByTestId('composer-quote-abc'); + await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'caption'); + const sendPromise = user.press(screen.getByTestId('message-composer-send')); + await waitFor(() => expect(sendFileMessage).toHaveBeenCalled()); + await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'typed while uploading'); + resolveUpload(); + await sendPromise; + + await waitFor(() => expect(composerRef.current?.getText()).toBe('')); + expect(screen.queryByTestId('composer-quote-abc')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('message-composer-attachments')).not.toBeOnTheScreen(); + }); + + test('restores input and keeps attachments after a failed upload', async () => { + (sendFileMessage as jest.Mock).mockRejectedValueOnce(new Error('upload failed')); + const composerRef = { current: null } as RefObject; + render( + + + + ); + await screen.findByTestId('message-composer-attachment-0'); + await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'caption'); + await user.press(screen.getByTestId('message-composer-send')); + + await waitFor(() => expect(composerRef.current?.getText()).toBe('caption')); + expect(screen.getByTestId('message-composer-attachments')).toBeOnTheScreen(); + expect(screen.getByTestId('composer-quote-abc')).toBeOnTheScreen(); + }); + }); + + describe('media transfer ownership', () => { + const attachment = { filename: 'legacy.pdf', size: 12, mime: 'application/pdf', path: 'file:///tmp/legacy.pdf' }; + + test('legacy transfer reads current text while retaining Quote IDs captured by the initiating render', async () => { + let resolveDocument!: (result: unknown) => void; + (require('expo-document-picker').getDocumentAsync as jest.Mock).mockReturnValueOnce( + new Promise(resolve => (resolveDocument = resolve)) + ); + const ref = { current: null } as RefObject; + render( + + + + ); + await waitFor(() => expect(mediaTransferProbe).toBeDefined()); + + const choosePromise = mediaTransferProbe.chooseFile(); + ref.current?.setInput('awaiting text'); + mediaActionStore.getState().actions.startReacting('react-now'); + resolveDocument({ + canceled: false, + assets: [{ name: attachment.filename, size: attachment.size, mimeType: attachment.mime, uri: attachment.path }] + }); + await choosePromise; + ref.current?.setInput('current text'); + mediaActionStore.getState().actions.startReacting('react-after-resolution'); + + const navigate = require('../../lib/navigation/appNavigation').navigate as jest.Mock; + const params = navigate.mock.calls[0][1]; + expect(params.startShareView()).toEqual({ text: 'current text', selectedMessages: ['old-quote'] }); + params.finishShareView('', []); + expect(ref.current?.getText()).toBe(''); + expect(mediaActionStore.getState().action).toBeNull(); + }); + + test.each(['chooseFile', 'takePhoto', 'chooseFromLibrary'] as const)( + '%s cancellation leaves input and Quotes unchanged', + async method => { + const ref = { current: null } as RefObject; + render( + + + + ); + await waitFor(() => expect(mediaTransferProbe).toBeDefined()); + ref.current?.setInput('kept text'); + if (method === 'chooseFile') { + (require('expo-document-picker').getDocumentAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + } else { + const imagePicker = require('../../lib/methods/helpers/ImagePicker/ImagePicker').default; + imagePicker[method === 'takePhoto' ? 'openCamera' : 'openPicker'].mockRejectedValueOnce(new Error('cancelled')); + } + + await mediaTransferProbe[method](); + expect(require('../../lib/navigation/appNavigation').navigate).not.toHaveBeenCalled(); + expect(ref.current?.getText()).toBe('kept text'); + expect(mediaActionStore.getState().action).toEqual({ kind: 'quote', messageIds: ['kept-quote'] }); + } + ); + + test('same-rid Room and Thread composers transfer and restore independently', async () => { + const roomRef = { current: null } as RefObject; + const threadRef = { current: null } as RefObject; + const Dual = () => ( + + + + + + + + + + + + + + + + + ); + (require('expo-document-picker').getDocumentAsync as jest.Mock).mockResolvedValue({ + canceled: false, + assets: [{ name: 'x.pdf', size: 1, mimeType: 'application/pdf', uri: 'file:///x.pdf' }] + }); + render(); + await waitFor(() => expect(mediaTransferProbes.room).toBeDefined()); + roomRef.current?.setInput('room text'); + threadRef.current?.setInput('thread text'); + await Promise.all([mediaTransferProbes.room.chooseFile(), mediaTransferProbes.thread.chooseFile()]); + + const navigate = require('../../lib/navigation/appNavigation').navigate as jest.Mock; + expect(navigate).toHaveBeenCalledTimes(2); + const roomParams = navigate.mock.calls.find(([, params]) => params.thread === '')[1]; + const threadParams = navigate.mock.calls.find(([, params]) => params.thread?.id === 'thread-id')[1]; + expect(roomParams.startShareView().text).toBe('room text'); + expect(threadParams.startShareView().text).toBe('thread text'); + roomParams.finishShareView('room restored', ['room-restored']); + threadParams.finishShareView('thread restored', ['thread-restored']); + expect(roomRef.current?.getText()).toBe('room restored'); + expect(threadRef.current?.getText()).toBe('thread restored'); + expect(mediaActionStores.room.getState().action).toEqual({ kind: 'quote', messageIds: ['room-restored'] }); + expect(mediaActionStores.thread.getState().action).toEqual({ kind: 'quote', messageIds: ['thread-restored'] }); + }); + + test('alt-text capable workspaces keep selected media inline', async () => { + (useAltTextSupported as jest.Mock).mockReturnValue(true); + (require('expo-document-picker').getDocumentAsync as jest.Mock).mockResolvedValueOnce({ + canceled: false, + assets: [{ name: 'inline.pdf', size: 1, mimeType: 'application/pdf', uri: 'file:///inline.pdf' }] + }); + render( + + + + ); + await waitFor(() => expect(mediaTransferProbe).toBeDefined()); + await mediaTransferProbe.chooseFile(); + await waitFor(() => expect(screen.getByTestId('message-composer-attachments')).toBeOnTheScreen()); + expect(require('../../lib/navigation/appNavigation').navigate).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index 824580c69b0..692582cd4e7 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -4,12 +4,7 @@ import { useBackHandler } from '@react-native-community/hooks'; import { Q } from '@nozbe/watermelondb'; import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; -import { - useComposerRid, - useComposerSharing, - useComposerStoreApi, - useComposerTmid -} from '../../views/RoomView/stores/ComposerStore'; +import { useComposerRid, useComposerSharing, useComposerStoreApi, useComposerTmid } from './ComposerStore'; import { useMessageActionKind, useMessageActionStoreApi } from '../message/stores/MessageActionStore'; import { Autocomplete } from './components'; import { MIN_HEIGHT } from './constants'; @@ -103,7 +98,7 @@ export const MessageComposer = ({ const handleSendMessage = async () => { if (!rid) return; - const { editRequest, onSendMessage, setQuotesAndText } = composerStore.getState(); + const { editRequest, onSendMessage } = composerStore.getState(); const { action } = messageActionStore.getState(); const editingMessageId = action?.kind === 'edit' ? action.messageId : undefined; const quotedMessageIds = action?.kind === 'quote' ? action.messageIds : []; @@ -151,7 +146,8 @@ export const MessageComposer = ({ getMsg: ({ description }, index) => (index === 0 ? description || quotedMessage || textFromInput : description) }); clearAttachments(); - setQuotesAndText?.('', []); + messageActionStore.getState().actions.setQuoteMessageIds([]); + composerInputComponentRef.current?.setInput(''); return; } catch (e) { log(e); @@ -250,6 +246,8 @@ export const MessageComposer = ({ value={{ sendMessage: handleSendMessage, onEmojiSelected, + getText: () => composerInputComponentRef.current?.getText(), + setInput: text => composerInputComponentRef.current?.setInput(text), closeEmojiKeyboardAndAction, focus: focusComposerInput }}> diff --git a/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx b/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx index 7f66d0167d9..72542d414a0 100644 --- a/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx +++ b/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx @@ -8,7 +8,7 @@ import { AutocompleteItem } from './AutocompleteItem'; import { useAutocomplete } from '../../hooks'; import { type IAutocompleteItemProps } from '../../interfaces'; import { AutocompletePreview } from './AutocompletePreview'; -import { useComposerRid, useUpdateAutocompleteVisible } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerRid, useUpdateAutocompleteVisible } from '../../ComposerStore'; import { useAutocompleteA11yAnnounce } from './useAutocompleteA11yAnnounce'; import { useStyle } from './styles'; diff --git a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx index 4cd3e646db2..1992597f0d8 100644 --- a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx +++ b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx @@ -3,8 +3,8 @@ import { AccessibilityInfo } from 'react-native'; import { type ReactNode } from 'react'; import { useAutocompleteA11yAnnounce } from './useAutocompleteA11yAnnounce'; -import { createComposerStore, ComposerStoreContext } from '../../../../views/RoomView/stores/ComposerStore'; -import { type ComposerStore } from '../../../../views/RoomView/definitions'; +import { createComposerStore, ComposerStoreContext } from '../../ComposerStore'; +import { type ComposerStore } from '../../ComposerStore'; const externalState = { rid: 'rid-1', @@ -15,9 +15,7 @@ const externalState = { editCancel: jest.fn(), editRequest: jest.fn(() => Promise.resolve()), onRemoveQuoteMessage: jest.fn(), - onSendMessage: jest.fn(), - setQuotesAndText: jest.fn(), - getText: jest.fn(() => '') + onSendMessage: jest.fn() }; const setup = () => { diff --git a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts index ee547191239..24c068ec00a 100644 --- a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts +++ b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { AccessibilityInfo } from 'react-native'; import I18n from '../../../../i18n'; -import { useIsAutocompleteVisible } from '../../../../views/RoomView/stores/ComposerStore'; +import { useIsAutocompleteVisible } from '../../ComposerStore'; const DELAY_TO_AVOID_KEYBOARD_ANNOUNCEMENT_CONFLICT = 800; diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx index 6c4833049f1..6a131f650ba 100644 --- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx @@ -9,7 +9,7 @@ import Navigation from '../../../../lib/navigation/appNavigation'; import { useMasterDetail } from '../../../../lib/hooks/useMasterDetail'; import { usePermissions } from '../../../../lib/hooks/usePermissions'; import { useCanUploadFile, useChooseMedia } from '../../hooks'; -import { useComposerRid, useComposerTmid, useComposerType } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerRid, useComposerTmid, useComposerType } from '../../ComposerStore'; export const ActionsButton = () => { const rid = useComposerRid(); diff --git a/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx b/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx index d1d22c8c08a..ca49d8a7463 100644 --- a/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx @@ -7,7 +7,7 @@ import i18n from '../../../../i18n'; import { useAppSelector } from '../../../../lib/hooks/useAppSelector'; import { openAppSettings } from '../../../../lib/methods/helpers/openAppSettings'; import { useTheme } from '../../../../theme'; -import { useComposerRid, useComposerSharing } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerRid, useComposerSharing } from '../../ComposerStore'; import { MessageInnerContext, useComposerAttachments, useMessageComposerApi, useMicOrSend } from '../../context'; import { useCanUploadFile } from '../../hooks'; import { BaseButton } from './BaseButton'; diff --git a/app/containers/MessageComposer/components/CancelEdit.tsx b/app/containers/MessageComposer/components/CancelEdit.tsx index a065bfbe1fe..4096b658c15 100644 --- a/app/containers/MessageComposer/components/CancelEdit.tsx +++ b/app/containers/MessageComposer/components/CancelEdit.tsx @@ -1,5 +1,5 @@ import { BaseButton } from './Buttons'; -import { useEditCancel } from '../../../views/RoomView/stores/ComposerStore'; +import { useEditCancel } from '../ComposerStore'; import { useMessageActionKind } from '../../message/stores/MessageActionStore'; import { Gap } from './Gap'; diff --git a/app/containers/MessageComposer/components/ComposerInput.test.tsx b/app/containers/MessageComposer/components/ComposerInput.test.tsx new file mode 100644 index 00000000000..996d9a9d911 --- /dev/null +++ b/app/containers/MessageComposer/components/ComposerInput.test.tsx @@ -0,0 +1,192 @@ +import { createRef } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { createStore } from 'zustand'; + +import { ComposerInput } from './ComposerInput'; +import { MessageComposerProvider, useAutocompleteParams } from '../context'; +import { ComposerProvider } from '../ComposerStore'; +import { RoomStoreContext } from '../../../views/RoomView/stores/RoomStoreContext'; +import { createMessageActionStore, MessageActionProvider } from '../../message/stores/MessageActionStore'; +import { type IComposerInput } from '../interfaces'; +import { loadDraftMessage } from '../../../lib/methods/draftMessage'; + +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + const React = jest.requireActual('react'); + const NativeTextInput = React.forwardRef((props: Record, ref: unknown) => { + const nativeNode = React.useRef({ + focus: jest.fn(), + setNativeProps: jest.fn(), + setSelection: jest.fn() + }).current; + React.useImperativeHandle(ref, () => nativeNode, []); + return React.createElement('TextInput', props); + }); + const mocked = Object.create(Object.getPrototypeOf(actual)); + for (const key of Object.getOwnPropertyNames(actual)) { + if (key !== 'TextInput') { + Object.defineProperty(mocked, key, Object.getOwnPropertyDescriptor(actual, key)!); + } + } + mocked.TextInput = NativeTextInput; + return mocked; +}); + +jest.mock('react-redux', () => ({ useDispatch: jest.fn(() => jest.fn()) })); +jest.mock('@react-navigation/native', () => ({ + useRoute: jest.fn(() => ({ params: {} })), + useFocusEffect: jest.fn() +})); +jest.mock('../../../lib/hooks/useAltTextSupported', () => ({ useAltTextSupported: jest.fn(() => false) })); +jest.mock('../../../lib/hooks/useMasterDetail', () => ({ useMasterDetail: jest.fn(() => false) })); +jest.mock('../../../lib/methods/helpers/externalInput', () => ({ isExternalKeyboardConnected: jest.fn(() => false) })); +jest.mock('../hooks/useIOSBackSwipeHandler', () => ({ + __esModule: true, + default: jest.fn(() => ({ iOSBackSwipe: { current: false } })) +})); +jest.mock('../hooks/useAutoSaveDraft', () => ({ useAutoSaveDraft: jest.fn(() => ({ saveMessageDraft: jest.fn() })) })); +jest.mock('../../../lib/methods/draftMessage', () => ({ loadDraftMessage: jest.fn(() => Promise.resolve(undefined)) })); + +const mockLoadDraftMessage = loadDraftMessage as jest.Mock; + +const AutocompleteProbe = () => { + const params = useAutocompleteParams(); + return {`${params.type}:${params.text}`}; +}; + +const composerState = { + rid: 'room-1', + t: 'c', + tmid: undefined, + room: undefined, + sharing: false, + onRemoveQuoteMessage: jest.fn() +}; + +const createRoomStore = () => + createStore()(() => ({ + room: composerState.room, + roomUpdate: undefined + })); + +const renderInput = ({ + action, + sharing = false +}: { + action?: Parameters[0]['initialAction']; + sharing?: boolean; +} = {}) => { + const composerRef = createRef(); + const inputRef = createRef(); + const roomStore = createRoomStore(); + const messageActionStore = createMessageActionStore(action); + const tree = ( + + + + + <> + + + + + + + + ); + const rendered = render(tree); + return { ...rendered, composerRef, inputRef, messageActionStore }; +}; + +describe('ComposerInput', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockLoadDraftMessage.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('updates getText synchronously and trims typed text while preserving raw native text', () => { + const { composerRef, inputRef } = renderInput(); + const input = screen.getByTestId('message-composer-input'); + + fireEvent.changeText(input, ' typed text '); + + expect(composerRef.current?.getText()).toBe('typed text'); + expect(inputRef.current?.setNativeProps).toHaveBeenCalledWith({ text: ' typed text ' }); + }); + + it('updates getText and the native input immediately for programmatic input', () => { + const { composerRef, inputRef } = renderInput(); + + act(() => composerRef.current?.setInput(' programmatic text ')); + + expect(composerRef.current?.getText()).toBe('programmatic text'); + expect(inputRef.current?.setNativeProps).toHaveBeenCalledWith({ text: ' programmatic text ' }); + }); + + it('passes the typed raw value to debounced autocomplete before the input text is trimmed', () => { + const { composerRef } = renderInput(); + const input = screen.getByTestId('message-composer-input'); + + fireEvent(input, 'focus'); + fireEvent.changeText(input, '@alice '); + expect(composerRef.current?.getText()).toBe('@alice'); + + act(() => jest.advanceTimersByTime(500)); + + // The raw trailing space makes autocomplete stop; a trimmed value would produce an @ suggestion. + expect(screen.getByTestId('autocomplete').props.children).toBe('null:'); + }); + + it('delays explicit native selection by exactly 50 ms while getText is already updated', () => { + const { composerRef, inputRef } = renderInput(); + + act(() => composerRef.current?.setInput('hello', { start: 2, end: 2 })); + + expect(composerRef.current?.getText()).toBe('hello'); + expect(inputRef.current?.setSelection).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(49)); + expect(inputRef.current?.setSelection).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(1)); + expect(inputRef.current?.setSelection).toHaveBeenCalledWith(2, 2); + }); + + it('restores an empty draft text together with its Quotes', async () => { + mockLoadDraftMessage.mockResolvedValue(JSON.stringify({ msg: '', quotes: ['quoted-message'] })); + const { messageActionStore } = renderInput(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(messageActionStore.getState().action).toEqual({ kind: 'quote', messageIds: ['quoted-message'] }); + }); + + it('ignores structured drafts while sharing but restores them for a Room composer', async () => { + mockLoadDraftMessage.mockResolvedValue(JSON.stringify({ msg: 'room draft', quotes: ['room-quote'] })); + const sharing = renderInput({ sharing: true }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(sharing.messageActionStore.getState().action).toBeNull(); + expect(sharing.composerRef.current?.getText()).toBe(''); + + const room = renderInput({ sharing: false }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(room.messageActionStore.getState().action).toEqual({ kind: 'quote', messageIds: ['room-quote'] }); + expect(room.composerRef.current?.getText()).toBe('room draft'); + }); +}); diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 7fa78293e9b..04d5ee89afc 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -32,14 +32,8 @@ import { import database from '../../../lib/database'; import Navigation from '../../../lib/navigation/appNavigation'; import { emitter } from '../../../lib/methods/helpers/emitter'; -import { - useComposerRid, - useComposerRoom, - useComposerSharing, - useComposerTmid, - useSetQuotesAndText -} from '../../../views/RoomView/stores/ComposerStore'; -import { useMessageAction } from '../../message/stores/MessageActionStore'; +import { useComposerRid, useComposerRoom, useComposerSharing, useComposerTmid } from '../ComposerStore'; +import { useMessageAction, useMessageActionStoreApi } from '../../message/stores/MessageActionStore'; import { getMessageById } from '../../../lib/database/services/Message'; import { generateTriggerId } from '../../../lib/methods/actions'; import { executeCommandPreview } from '../../../lib/services/restApi'; @@ -60,7 +54,7 @@ export const ComposerInput = memo( const rid = useComposerRid(); const tmid = useComposerTmid(); const sharing = useComposerSharing(); - const setQuotesAndText = useSetQuotesAndText(); + const messageActionStore = useMessageActionStoreApi(); const room = useComposerRoom(); const action = useMessageAction(); const focused = useFocused(); @@ -97,7 +91,9 @@ export const ComposerInput = memo( if (draftMessage) { const parsedDraft = parseJson(draftMessage); if (parsedDraft?.msg || parsedDraft?.quotes) { - setQuotesAndText?.(parsedDraft.msg, parsedDraft.quotes); + if (sharing) return; + messageActionStore.getState().actions.setQuoteMessageIds(parsedDraft.quotes || []); + setInput(parsedDraft.msg || ''); } else { setInput(draftMessage); } diff --git a/app/containers/MessageComposer/components/Quotes/Quote.tsx b/app/containers/MessageComposer/components/Quotes/Quote.tsx index 498fd433a5f..ffaa235a361 100644 --- a/app/containers/MessageComposer/components/Quotes/Quote.tsx +++ b/app/containers/MessageComposer/components/Quotes/Quote.tsx @@ -3,7 +3,7 @@ import { View, Text } from 'react-native'; import dayjs from '../../../../lib/dayjs'; import { useTheme } from '../../../../theme'; import sharedStyles from '../../../../views/Styles'; -import { useComposerTmid, useOnRemoveQuoteMessage } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerTmid, useOnRemoveQuoteMessage } from '../../ComposerStore'; import { BaseButton } from '../Buttons'; import { useMessage } from '../../hooks'; import { useAppSelector } from '../../../../lib/hooks/useAppSelector'; diff --git a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx index 0e602c5f0e3..0e1ab82bd0a 100644 --- a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx +++ b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx @@ -16,7 +16,7 @@ import { RECORDING_EXTENSION, RECORDING_MODE, RECORDING_SETTINGS } from '../../. import { useAppSelector } from '../../../../lib/hooks/useAppSelector'; import log from '../../../../lib/methods/helpers/log'; import { type IUpload } from '../../../../definitions'; -import { useComposerRid, useComposerTmid } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerRid, useComposerTmid } from '../../ComposerStore'; import { useCanUploadFile } from '../../hooks'; import { Duration, type IDurationRef } from './Duration'; import AudioPlayer from '../../../AudioPlayer'; diff --git a/app/containers/MessageComposer/components/SendThreadToChannel.tsx b/app/containers/MessageComposer/components/SendThreadToChannel.tsx index 2605efbc22e..4e6877ff28e 100644 --- a/app/containers/MessageComposer/components/SendThreadToChannel.tsx +++ b/app/containers/MessageComposer/components/SendThreadToChannel.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, type ReactElement } from 'react'; import { type Subscription } from 'rxjs'; import { Q } from '@nozbe/watermelondb'; -import { useComposerTmid } from '../../../views/RoomView/stores/ComposerStore'; +import { useComposerTmid } from '../ComposerStore'; import { useAlsoSendThreadToChannel, useMessageComposerApi } from '../context'; import { CustomIcon } from '../../CustomIcon'; import { useTheme } from '../../../theme'; diff --git a/app/containers/MessageComposer/components/Toolbar/Default.tsx b/app/containers/MessageComposer/components/Toolbar/Default.tsx index db92991bdf4..cd70a4a80c7 100644 --- a/app/containers/MessageComposer/components/Toolbar/Default.tsx +++ b/app/containers/MessageComposer/components/Toolbar/Default.tsx @@ -5,7 +5,7 @@ import { ActionsButton, BaseButton } from '../Buttons'; import { useMessageComposerApi } from '../../context'; import { Gap } from '../Gap'; import { emitter } from '../../../../lib/methods/helpers/emitter'; -import { useComposerSharing } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerSharing } from '../../ComposerStore'; import { useEmojiKeyboard } from '../../hooks/useEmojiKeyboard'; export const Default = (): ReactElement | null => { diff --git a/app/containers/MessageComposer/components/Unfocused/Left.tsx b/app/containers/MessageComposer/components/Unfocused/Left.tsx index f684ae63ea1..a02d25ce5f5 100644 --- a/app/containers/MessageComposer/components/Unfocused/Left.tsx +++ b/app/containers/MessageComposer/components/Unfocused/Left.tsx @@ -5,7 +5,7 @@ import { useFocused } from '../../context'; import { useEmojiKeyboard } from '../../hooks/useEmojiKeyboard'; import { ActionsButton } from '../Buttons'; import { MIN_HEIGHT } from '../../constants'; -import { useComposerSharing } from '../../../../views/RoomView/stores/ComposerStore'; +import { useComposerSharing } from '../../ComposerStore'; export const Left = (): ReactElement | null => { const sharing = useComposerSharing(); diff --git a/app/containers/MessageComposer/context.tsx b/app/containers/MessageComposer/context.tsx index a51d03d5214..bc5c16537c8 100644 --- a/app/containers/MessageComposer/context.tsx +++ b/app/containers/MessageComposer/context.tsx @@ -84,6 +84,8 @@ export const useComposerAttachments = (): State['attachments'] => useComposerSto type TMessageInnerContext = { sendMessage(): void; onEmojiSelected(emoji: IEmoji): void; + getText(): string | undefined; + setInput(text: string): void; // TODO: onClosed should be required closeEmojiKeyboardAndAction(onClosed?: Function, params?: any): void; focus(): void; @@ -93,6 +95,8 @@ type TMessageInnerContext = { export const MessageInnerContext = createContext({ sendMessage: () => {}, onEmojiSelected: () => {}, + getText: () => '', + setInput: () => {}, closeEmojiKeyboardAndAction: () => {}, focus: () => {} }); diff --git a/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts b/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts index 528c03c15ad..365b23fc0ef 100644 --- a/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts +++ b/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts @@ -2,7 +2,7 @@ import { useRoute } from '@react-navigation/native'; import { useCallback, useEffect, useRef } from 'react'; import { saveDraftMessage } from '../../../lib/methods/draftMessage'; -import { useComposerRid, useComposerTmid } from '../../../views/RoomView/stores/ComposerStore'; +import { useComposerRid, useComposerTmid } from '../ComposerStore'; import { useMessageAction } from '../../message/stores/MessageActionStore'; import { useFocused } from '../context'; diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx index bffc8304d2a..306b2abe92a 100644 --- a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx +++ b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx @@ -11,17 +11,14 @@ jest.mock('../../../lib/hooks/useAppSelector', () => ({ })); jest.mock('../context', () => ({ - useMessageComposerApi: jest.fn() -})); - -jest.mock('../../../views/RoomView/stores/ComposerStore', () => ({ - useSetQuotesAndText: jest.fn(), - useGetText: jest.fn() + useMessageComposerApi: jest.fn(), + MessageInnerContext: require('react').createContext({ getText: jest.fn(() => 'draft'), setInput: jest.fn() }) })); jest.mock('../../message/stores/MessageActionStore', () => ({ useMessageActionKind: jest.fn(), - useQuotedMessageIds: jest.fn(() => []) + useQuotedMessageIds: jest.fn(() => []), + useMessageActionStoreApi: jest.fn(() => ({ getState: () => ({ actions: { setQuoteMessageIds: jest.fn() } }) })) })); jest.mock('../../../lib/hooks/useAltTextSupported', () => ({ @@ -51,8 +48,6 @@ jest.mock('../../../lib/methods/helpers/ImagePicker/ImagePicker', () => ({ const mockGetDocumentAsync = require('expo-document-picker').getDocumentAsync as jest.Mock; const mockUseAppSelector = require('../../../lib/hooks/useAppSelector').useAppSelector as jest.Mock; const mockUseMessageComposerApi = require('../context').useMessageComposerApi as jest.Mock; -const mockUseSetQuotesAndText = require('../../../views/RoomView/stores/ComposerStore').useSetQuotesAndText as jest.Mock; -const mockUseGetText = require('../../../views/RoomView/stores/ComposerStore').useGetText as jest.Mock; const mockUseMessageActionKind = require('../../message/stores/MessageActionStore').useMessageActionKind as jest.Mock; const mockUseQuotedMessageIds = require('../../message/stores/MessageActionStore').useQuotedMessageIds as jest.Mock; const mockUseAltTextSupported = require('../../../lib/hooks/useAltTextSupported').useAltTextSupported as jest.Mock; @@ -75,8 +70,6 @@ describe('useChooseMedia', () => { }) ); mockUseMessageComposerApi.mockReturnValue({ addAttachments }); - mockUseSetQuotesAndText.mockReturnValue(jest.fn()); - mockUseGetText.mockReturnValue(jest.fn(() => 'draft')); mockUseMessageActionKind.mockReturnValue(null); mockGetSubscriptionByRoomId.mockResolvedValue({ rid: 'room-id', t: 'c' }); mockGetThreadById.mockResolvedValue({ id: 'thread-id' }); diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.ts b/app/containers/MessageComposer/hooks/useChooseMedia.ts index d953ad83916..5cd6edca417 100644 --- a/app/containers/MessageComposer/hooks/useChooseMedia.ts +++ b/app/containers/MessageComposer/hooks/useChooseMedia.ts @@ -1,4 +1,5 @@ import * as DocumentPicker from 'expo-document-picker'; +import { useContext } from 'react'; import { IMAGE_PICKER_CONFIG, LIBRARY_PICKER_CONFIG, VIDEO_PICKER_CONFIG } from '../constants'; import { forceJpgExtension } from '../helpers'; @@ -9,11 +10,10 @@ import { getSubscriptionByRoomId } from '../../../lib/database/services/Subscrip import { getThreadById } from '../../../lib/database/services/Thread'; import Navigation from '../../../lib/navigation/appNavigation'; import { useAppSelector } from '../../../lib/hooks/useAppSelector'; -import { useGetText, useSetQuotesAndText } from '../../../views/RoomView/stores/ComposerStore'; -import { useMessageActionKind, useQuotedMessageIds } from '../../message/stores/MessageActionStore'; +import { useMessageActionKind, useMessageActionStoreApi, useQuotedMessageIds } from '../../message/stores/MessageActionStore'; import { type IShareAttachment } from '../../../definitions'; import ImagePicker, { type ImageOrVideo } from '../../../lib/methods/helpers/ImagePicker/ImagePicker'; -import { useMessageComposerApi } from '../context'; +import { MessageInnerContext, useMessageComposerApi } from '../context'; import { useAltTextSupported } from '../../../lib/hooks/useAltTextSupported'; const normalizeAttachment = (item: IShareAttachment) => @@ -30,9 +30,9 @@ export const useChooseMedia = ({ }) => { const { FileUpload_MediaTypeWhiteList, FileUpload_MaxFileSize } = useAppSelector(state => state.settings); const { addAttachments } = useMessageComposerApi(); - const setQuotesAndText = useSetQuotesAndText(); - const getText = useGetText(); + const { getText, setInput } = useContext(MessageInnerContext); const actionKind = useMessageActionKind(); + const messageActionStore = useMessageActionStoreApi(); const quotedMessageIds = useQuotedMessageIds(); const altTextSupported = useAltTextSupported(); const allowList = FileUpload_MediaTypeWhiteList as string; @@ -95,14 +95,17 @@ export const useChooseMedia = ({ }; const startShareView = () => { - const text = getText?.() || ''; + const text = getText() || ''; return { selectedMessages: quotedMessageIds, text }; }; - const finishShareView = (text = '', quotes = []) => setQuotesAndText?.(text, quotes); + const finishShareView = (text = '', quotes: string[] = []) => { + messageActionStore.getState().actions.setQuoteMessageIds(quotes); + setInput(text); + }; const openShareView = async (attachments: any) => { if (!rid) return; diff --git a/app/containers/MessageComposer/hooks/useEmojiKeyboard.test.tsx b/app/containers/MessageComposer/hooks/useEmojiKeyboard.test.tsx index 1e3950f3021..54c0afeaa7c 100644 --- a/app/containers/MessageComposer/hooks/useEmojiKeyboard.test.tsx +++ b/app/containers/MessageComposer/hooks/useEmojiKeyboard.test.tsx @@ -64,7 +64,9 @@ describe('useEmojiKeyboard', () => { sendMessage: jest.fn(), onEmojiSelected: jest.fn(), closeEmojiKeyboardAndAction: jest.fn(), - focus + focus, + getText: jest.fn(() => ''), + setInput: jest.fn() }; return ({ children }: { children: ReactElement }) => ( @@ -222,7 +224,9 @@ describe('useEmojiKeyboard', () => { sendMessage: jest.fn(), onEmojiSelected: jest.fn(), closeEmojiKeyboardAndAction: jest.fn(), - focus: mockFocus + focus: mockFocus, + getText: jest.fn(() => ''), + setInput: jest.fn() }; const wrapper = ({ children }: { children: ReactElement }) => ( diff --git a/app/containers/MessageComposer/index.tsx b/app/containers/MessageComposer/index.tsx index e4096f60c34..6c8b04d6bfe 100644 --- a/app/containers/MessageComposer/index.tsx +++ b/app/containers/MessageComposer/index.tsx @@ -1,2 +1,3 @@ export * from './interfaces'; export * from './MessageComposerContainer'; +export * from './ComposerStore'; diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index f376d738a3f..06778db2d44 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -13,7 +13,7 @@ import NavBottomFAB from './NavBottomFAB'; import { type TAnyMessageModel } from '../../../../definitions'; import { type IListProps } from '../../definitions'; import { SCROLL_LIMIT } from '../constants'; -import { useIsAutocompleteVisible } from '../../stores/ComposerStore'; +import { useIsAutocompleteVisible } from '../../../../containers/MessageComposer/ComposerStore'; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); diff --git a/app/views/RoomView/RoomScreen.tsx b/app/views/RoomView/RoomScreen.tsx index dc9a05d7a31..be29bef3ef6 100644 --- a/app/views/RoomView/RoomScreen.tsx +++ b/app/views/RoomView/RoomScreen.tsx @@ -51,9 +51,7 @@ const RoomScreen = ({ route, rid, t, tmid, roomStore, ready }: IRoomScreenProps) onRemoveQuoteMessage, onReactionInit, onReactionPress, - onReplyInit, - setQuotesAndText, - getText + onReplyInit } = useRoomMessaging({ rid, t, @@ -92,9 +90,7 @@ const RoomScreen = ({ route, rid, t, tmid, roomStore, ready }: IRoomScreenProps) onRemoveQuoteMessage={onRemoveQuoteMessage} editCancel={onEditCancel} editRequest={onEditRequest} - onSendMessage={sendMessage} - setQuotesAndText={setQuotesAndText} - getText={getText}> + onSendMessage={sendMessage}> {!tmid ? : null} { const { createElement } = require('react'); const { Pressable, View } = require('react-native'); const { useRoomScreen } = require('../stores/RoomScreenContext'); - const { useComposerTmid, useOnSendMessage } = require('../stores/ComposerStore'); + const { useComposerTmid, useOnSendMessage } = require('../../../containers/MessageComposer/ComposerStore'); return { __esModule: true, default: () => { diff --git a/app/views/RoomView/components/RoomProviders.test.tsx b/app/views/RoomView/components/RoomProviders.test.tsx index 30ab640db2a..13b92f240cb 100644 --- a/app/views/RoomView/components/RoomProviders.test.tsx +++ b/app/views/RoomView/components/RoomProviders.test.tsx @@ -1,7 +1,7 @@ import { act, render } from '@testing-library/react-native'; import { RoomProviders } from './RoomProviders'; -import { useComposerRid, useComposerSharing } from '../stores/ComposerStore'; +import { useComposerRid, useComposerSharing } from '../../../containers/MessageComposer/ComposerStore'; import { createMessageActionStore, useIsBeingEdited, @@ -40,30 +40,53 @@ describe('RoomProviders', () => { it('resolves useMessageAction/useIsBeingEdited to the store passed in props, not some other store', () => { const store = createMessageActionStore(); - const actionSpy = jest.fn(); + const otherStore = createMessageActionStore(); + const rowActionSpy = jest.fn(); + const composerActionSpy = jest.fn(); + const otherActionSpy = jest.fn(); const isBeingEditedSpy = jest.fn(); const ridSpy = jest.fn(); - const Probe = () => { - actionSpy(useMessageAction()); + const RowProbe = () => { + rowActionSpy(useMessageAction()); + return null; + }; + const ComposerProbe = () => { + composerActionSpy(useMessageAction()); isBeingEditedSpy(useIsBeingEdited('msg-1')); ridSpy(useComposerRid()); return null; }; + const OtherProbe = () => { + otherActionSpy(useMessageAction()); + return null; + }; render( - - - + <> + + <> + + + + + + + + ); expect(ridSpy).toHaveBeenLastCalledWith('rid-1'); - expect(actionSpy).toHaveBeenLastCalledWith(null); + expect(rowActionSpy).toHaveBeenLastCalledWith(null); + expect(composerActionSpy).toHaveBeenLastCalledWith(null); + expect(otherActionSpy).toHaveBeenLastCalledWith(null); expect(isBeingEditedSpy).toHaveBeenLastCalledWith(false); act(() => store.getState().actions.startEditing('msg-1')); - expect(actionSpy).toHaveBeenLastCalledWith({ kind: 'edit', messageId: 'msg-1' }); + expect(rowActionSpy).toHaveBeenLastCalledWith({ kind: 'edit', messageId: 'msg-1' }); + expect(composerActionSpy).toHaveBeenLastCalledWith({ kind: 'edit', messageId: 'msg-1' }); + expect(otherActionSpy).toHaveBeenLastCalledWith(null); expect(isBeingEditedSpy).toHaveBeenLastCalledWith(true); }); }); diff --git a/app/views/RoomView/components/RoomProviders.tsx b/app/views/RoomView/components/RoomProviders.tsx index 022f1ed3de5..59cfcae532a 100644 --- a/app/views/RoomView/components/RoomProviders.tsx +++ b/app/views/RoomView/components/RoomProviders.tsx @@ -1,7 +1,6 @@ import { type ReactElement } from 'react'; -import { type TComposerExternalState } from '../definitions'; -import { ComposerProvider } from '../stores/ComposerStore'; +import { type TComposerExternalState, ComposerProvider } from '../../../containers/MessageComposer/ComposerStore'; import { type TMessageActionStore, MessageActionProvider } from '../../../containers/message/stores/MessageActionStore'; type IRoomProvidersProps = TComposerExternalState & { @@ -21,9 +20,7 @@ export const RoomProviders = ({ editCancel, editRequest, onRemoveQuoteMessage, - onSendMessage, - setQuotesAndText, - getText + onSendMessage }: IRoomProvidersProps): ReactElement => ( + onSendMessage={onSendMessage}> {children} diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index ce959453062..c5f32680940 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -75,25 +75,6 @@ export interface IRoomViewState { lastSeen: Date | null; } -export type ComposerState = { - rid?: string; - t?: string; - tmid?: string; - room: IRoomViewState['room']; - roomUpdate?: IRoomViewState['roomUpdate']; - sharing?: boolean; - isAutocompleteVisible: boolean; - editCancel?: () => void; - editRequest?: (message: Pick & { attachments?: IMessageEditAttachment[] }) => Promise; - onRemoveQuoteMessage?: (messageId: string) => void; - onSendMessage?: (message?: string, tshow?: boolean) => void; - setQuotesAndText?: (text: string, quotes: string[]) => void; - getText?: () => string | undefined; - updateAutocompleteVisible: (updatedAutocompleteVisible: boolean) => void; -}; - -export type TComposerExternalState = Omit; - export interface IUseE2EEStatusResult { showMissingE2EEKey: boolean; showE2EEDisabledRoom: boolean; @@ -189,8 +170,6 @@ export interface IJoinRoomContext { export type RoomStore = StoreApi; -export type ComposerStore = StoreApi; - export type TGetMessageInfoResult = { id: string; rid: string | undefined; @@ -266,8 +245,6 @@ export interface IUseMessageActionsResult { onReactionInit: (messageId: string) => void; onMessageLongPress: (message: TAnyMessageModel) => void; onReplyInit: (messageId: string) => Promise; - setQuotesAndText: (text: string, quotes: string[]) => void; - getText: () => string | undefined; } export interface IRoomMessageListProps diff --git a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx index f312e7ed5aa..d351d8676ec 100644 --- a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx @@ -39,8 +39,7 @@ const createRefs = () => ({ messageComposerRef: { current: { closeEmojiKeyboardAndAction: jest.fn((action?: Function, params?: any) => action?.(params)), - setInput: jest.fn(), - getText: jest.fn(() => 'composer-text') + setInput: jest.fn() } }, messageActionsRef: { @@ -344,31 +343,4 @@ describe('useMessageActions', () => { expect(onThreadPress).not.toHaveBeenCalled(); }); }); - - describe('composer bridge', () => { - it('setQuotesAndText stores the quote ids and forwards text to the composer', () => { - const { result, messageActionStore, refs } = renderMessageActions(); - - act(() => result.current.setQuotesAndText('hello', ['msg-1', 'msg-2'])); - - expect(messageActionStore.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] }); - expect(refs.messageComposerRef.current.setInput).toHaveBeenCalledWith('hello'); - }); - - it('setQuotesAndText clears quotes and defaults empty text', () => { - const { result, messageActionStore, refs } = renderMessageActions(); - - act(() => result.current.setQuotesAndText('', [])); - - expect(messageActionStore.getState().action).toBeNull(); - expect(refs.messageComposerRef.current.setInput).toHaveBeenCalledWith(''); - }); - - it('getText reads the current composer text', () => { - const { result, refs } = renderMessageActions(); - - expect(result.current.getText()).toBe('composer-text'); - expect(refs.messageComposerRef.current.getText).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx new file mode 100644 index 00000000000..27b391119a5 --- /dev/null +++ b/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx @@ -0,0 +1,155 @@ +import { act, render, renderHook } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +import { sendMessage as sendMessageRequest } from '../../../../lib/methods/sendMessage'; +import { Review } from '../../../../lib/methods/helpers/review'; +import { events, logEvent } from '../../../../lib/methods/helpers/log'; +import { MessageActionProvider, useMessageAction } from '../../../../containers/message/stores/MessageActionStore'; +import { useRoomMessaging } from '../useRoomMessaging'; + +jest.mock('../../../../lib/methods/sendMessage', () => ({ + sendMessage: jest.fn() +})); +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' } +})); +jest.mock('../../../../lib/hooks/useAppSelector', () => ({ + useAppSelector: jest.fn((selector: (state: unknown) => unknown) => + selector({ login: { isAuthenticated: true, user: { id: 'user-1', username: 'alice', token: 'token-1' } } }) + ) +})); +jest.mock('../../../../lib/hooks/useMasterDetail', () => ({ useMasterDetail: jest.fn(() => false) })); +jest.mock('../../../../containers/ActionSheet', () => ({ + useActionSheet: jest.fn(() => ({ showActionSheet: jest.fn(), hideActionSheet: jest.fn() })) +})); +jest.mock('../useRoomNavigation', () => ({ + useRoomNavigation: jest.fn(() => ({ + onThreadMessagesLoaded: jest.fn(), + onThreadPress: jest.fn(), + jumpToMessageByUrl: jest.fn() + })) +})); +jest.mock('../useRoomInit', () => ({ + useRoomInit: jest.fn(() => ({ + loading: false, + failed: false, + retry: jest.fn(), + lastSeen: 'last-seen-message', + clearLastSeen: jest.fn() + })) +})); + +const mockSendMessageRequest = sendMessageRequest as jest.Mock; +const mockReview = Review.pushPositiveEvent as jest.Mock; +const mockLogEvent = logEvent as jest.Mock; + +const createDeferred = () => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +}; + +const renderRoomMessaging = () => { + const roomStore = {} as any; + return renderHook(() => + useRoomMessaging({ + rid: 'room-1', + t: 'c', + tmid: 'thread-1', + ready: true, + roomStore, + roomUserId: 'user-1' + }) + ); +}; + +const ActionProbe = () => {useMessageAction()?.kind ?? 'none'}; + +describe('useRoomMessaging', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shares its action store with message rows and resets the action while sending is pending', async () => { + const deferred = createDeferred(); + mockSendMessageRequest.mockReturnValue(deferred.promise); + const { result } = renderRoomMessaging(); + const actionProbe = render( + + + + ); + + act(() => result.current.messageActionStore.getState().actions.startQuote('quoted-message')); + expect(actionProbe.getByTestId('action').props.children).toBe('quote'); + + let sendPromise: void; + act(() => { + sendPromise = result.current.sendMessage('hello', true); + }); + + expect(mockSendMessageRequest).toHaveBeenCalledWith( + 'room-1', + 'hello', + 'thread-1', + expect.objectContaining({ id: 'user-1', username: 'alice', token: 'token-1' }), + true + ); + expect(mockLogEvent).toHaveBeenCalledWith(events.ROOM_SEND_MESSAGE); + expect(result.current.messageActionStore.getState().action).toBeNull(); + expect(actionProbe.getByTestId('action').props.children).toBe('none'); + expect(mockReview).not.toHaveBeenCalled(); + + await act(async () => { + deferred.resolve(); + await sendPromise; + }); + + expect(mockReview).toHaveBeenCalledTimes(1); + }); + + it('clears Last Seen and reports success only after the request resolves', async () => { + const deferred = createDeferred(); + mockSendMessageRequest.mockReturnValue(deferred.promise); + const { result } = renderRoomMessaging(); + const clearLastSeen = require('../useRoomInit').useRoomInit.mock.results[0].value.clearLastSeen as jest.Mock; + + act(() => result.current.sendMessage('hello')); + expect(clearLastSeen).not.toHaveBeenCalled(); + expect(mockReview).not.toHaveBeenCalled(); + + await act(async () => { + deferred.resolve(); + await Promise.resolve(); + }); + + expect(clearLastSeen).toHaveBeenCalledTimes(1); + expect(mockReview).toHaveBeenCalledTimes(1); + }); + + it('does not perform success side effects when the request fails', async () => { + const deferred = createDeferred(); + mockSendMessageRequest.mockReturnValue(deferred.promise); + const { result } = renderRoomMessaging(); + const clearLastSeen = require('../useRoomInit').useRoomInit.mock.results[0].value.clearLastSeen as jest.Mock; + + act(() => result.current.sendMessage('hello')); + await act(async () => { + deferred.reject(new Error('offline')); + await Promise.resolve(); + }); + + expect(clearLastSeen).not.toHaveBeenCalled(); + expect(mockReview).not.toHaveBeenCalled(); + }); +}); diff --git a/app/views/RoomView/hooks/useMessageActions.tsx b/app/views/RoomView/hooks/useMessageActions.tsx index 64a13149239..47600c25e26 100644 --- a/app/views/RoomView/hooks/useMessageActions.tsx +++ b/app/views/RoomView/hooks/useMessageActions.tsx @@ -116,13 +116,6 @@ export function useMessageActions({ onThreadPress(message); }; - const setQuotesAndText = (text: string, quotes: string[]) => { - messageActionStore.getState().actions.setQuoteMessageIds(quotes); - messageComposerRef.current?.setInput(text || ''); - }; - - const getText = () => messageComposerRef.current?.getText(); - return { resetAction, handleCloseEmoji, @@ -135,8 +128,6 @@ export function useMessageActions({ onReactionPress, onReactionInit, onMessageLongPress, - onReplyInit, - setQuotesAndText, - getText + onReplyInit }; } diff --git a/app/views/RoomView/hooks/useRoomMessaging.ts b/app/views/RoomView/hooks/useRoomMessaging.ts index 51e47429576..dcc8990f33f 100644 --- a/app/views/RoomView/hooks/useRoomMessaging.ts +++ b/app/views/RoomView/hooks/useRoomMessaging.ts @@ -55,9 +55,7 @@ export function useRoomMessaging({ rid, t, tmid, roomStore, ready, roomUserId, q onReactionPress, onReactionInit, onMessageLongPress, - onReplyInit, - setQuotesAndText, - getText + onReplyInit } = useMessageActions({ messageActionStore, showActionSheet, @@ -104,8 +102,6 @@ export function useRoomMessaging({ rid, t, tmid, roomStore, ready, roomUserId, q onRemoveQuoteMessage, onReactionInit, onReactionPress, - onReplyInit, - setQuotesAndText, - getText + onReplyInit }; } diff --git a/app/views/RoomView/reactCompilerContract.test.ts b/app/views/RoomView/reactCompilerContract.test.ts index 27d37650a66..34ae8ad4141 100644 --- a/app/views/RoomView/reactCompilerContract.test.ts +++ b/app/views/RoomView/reactCompilerContract.test.ts @@ -9,6 +9,8 @@ const REPO_ROOT = path.resolve(__dirname, '../../..'); // A11yGate/MessageA11y* live under containers/message but are part of the RoomView compiler contract. // Named individually (not a directory scan) so pre-existing, unrelated files in that folder aren't pulled in. const EXTRA_FILES: string[] = [ + // ComposerStore moved into the shared MessageComposer module but remains part of the RoomView provider contract. + path.resolve(__dirname, '../../containers/MessageComposer/ComposerStore.tsx'), path.resolve(__dirname, '../../containers/message/stores/A11yGate.tsx'), path.resolve(__dirname, '../../containers/message/components/MessageA11yOrder.tsx'), path.resolve(__dirname, '../../containers/message/components/MessageA11yIndex.tsx') diff --git a/app/views/ShareView/ShareView.test.tsx b/app/views/ShareView/ShareView.test.tsx index ece9cf482cd..a1212db1740 100644 --- a/app/views/ShareView/ShareView.test.tsx +++ b/app/views/ShareView/ShareView.test.tsx @@ -1,6 +1,42 @@ -import { type ReactNode } from 'react'; +import { createRef, useEffect, type ReactElement } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +import { initStore } from '../../lib/store/auxStore'; +import { mockedStore } from '../../reducers/mockedStore'; +import { appStart } from '../../actions/app'; +import { RootEnum } from '../../definitions'; +import { RoomProviders } from '../RoomView/components/RoomProviders'; +import { MessageComposerContainer, type IMessageComposerRef } from '../../containers/MessageComposer'; +import { createMessageActionStore } from '../../containers/message/stores/MessageActionStore'; +import { useChooseMedia } from '../../containers/MessageComposer/hooks/useChooseMedia'; + +jest.mock('expo-document-picker', () => ({ + getDocumentAsync: jest.fn() +})); +jest.mock('../../lib/navigation/appNavigation', () => ({ + navigate: jest.fn() +})); +jest.mock('../../lib/database/services/Subscription', () => ({ + getSubscriptionByRoomId: jest.fn() +})); +jest.mock('../../lib/database/services/Thread', () => ({ + getThreadById: jest.fn() +})); +jest.mock('../../lib/hooks/useAltTextSupported', () => ({ + useAltTextSupported: jest.fn(() => false) +})); jest.mock('../../lib/database', () => ({ + active: { + get: jest.fn(() => ({ + query: jest.fn(() => ({ + fetch: jest.fn(() => Promise.resolve([])), + observe: jest.fn(() => ({ subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) })) + })) + })), + write: jest.fn((callback: () => unknown) => callback()) + }, servers: { get: jest.fn(() => ({ find: jest.fn(() => Promise.resolve({})) @@ -8,21 +44,11 @@ jest.mock('../../lib/database', () => ({ } })); -jest.mock('../../containers/MessageComposer', () => { - const { forwardRef } = require('react'); - - const MessageComposerContainer = forwardRef(({ children }: { children: ReactNode }, _ref: unknown) => children); - MessageComposerContainer.displayName = 'MessageComposerContainer'; - - return { - MessageComposerContainer - }; -}); - jest.mock('./Preview', () => () => null); jest.mock('../../containers/Thumbs', () => () => null); jest.mock('../../containers/ActionSheet', () => ({ - showActionSheetRef: jest.fn() + showActionSheetRef: jest.fn(), + useActionSheet: () => ({ showActionSheet: jest.fn() }) })); jest.mock('../../containers/MessageComposer/components/Attachments/AttachmentActionSheet', () => ({ AttachmentActionSheet: () => null @@ -34,6 +60,15 @@ jest.mock('../../lib/methods/sendMessage', () => ({ const { showActionSheetRef } = require('../../containers/ActionSheet'); const { AttachmentActionSheet } = require('../../containers/MessageComposer/components/Attachments/AttachmentActionSheet'); const { ShareView } = require('./index'); +const mockGetSubscriptionByRoomId = require('../../lib/database/services/Subscription').getSubscriptionByRoomId as jest.Mock; + +initStore(mockedStore); + +const OriginMediaProbe = ({ onReady }: { onReady: (chooseFile: () => Promise) => void }): ReactElement | null => { + const { chooseFile } = useChooseMedia({ rid: 'room-id', tmid: undefined, permissionToUpload: true }); + useEffect(() => onReady(chooseFile), [chooseFile, onReady]); + return null; +}; const makeInstance = ({ mime, @@ -117,6 +152,13 @@ const makeInstance = ({ describe('ShareView', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetSubscriptionByRoomId.mockResolvedValue({ + rid: 'room-id', + t: 'c', + roles: [], + update: jest.fn(), + observe: () => ({ subscribe: () => ({ unsubscribe: jest.fn() }) }) + }); }); it('selectFile selects the attachment and opens the alt text action sheet', () => { @@ -208,4 +250,362 @@ describe('ShareView', () => { spy.mockRestore(); prepareSpy.mockRestore(); }); + + it('send() clears the origin and closes before an attachment upload completes', async () => { + const shareView = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + shareView.state.attachments[0].canUpload = true; + shareView.saveSelectedDescription = jest.fn() as any; + const finishShareView = jest.fn(); + const sendFileMessageMod = require('../../lib/methods/sendFileMessage'); + let resolveUpload!: () => void; + const uploadSpy = jest + .spyOn(sendFileMessageMod, 'sendFileMessage') + .mockImplementationOnce(() => new Promise(resolve => (resolveUpload = resolve))); + (shareView as any).finishShareView = finishShareView; + (shareView as any).messageComposerRef = { current: { getText: () => 'caption', setInput: jest.fn() } }; + + shareView.send(); + await Promise.resolve(); + + expect(finishShareView).toHaveBeenCalledWith('', []); + expect((shareView as any).sentMessage).toBe(true); + expect(shareView.props.navigation.pop as jest.Mock).toHaveBeenCalledTimes(1); + shareView.componentWillUnmount(); + expect(finishShareView).toHaveBeenCalledTimes(1); + + resolveUpload(); + await Promise.resolve(); + uploadSpy.mockRestore(); + }); + + it('send() attempts to return current text when an upload fails after closing', async () => { + const shareView = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + shareView.state.attachments[0].canUpload = true; + const finishShareView = jest.fn(); + (shareView as any).finishShareView = finishShareView; + (shareView as any).messageComposerRef = { current: { getText: () => 'typed after start' } }; + shareView.messageActionStore.getState().actions.setQuoteMessageIds(['quote-after-send']); + const sendFileMessageMod = require('../../lib/methods/sendFileMessage'); + let rejectUpload!: (error: Error) => void; + const uploadSpy = jest + .spyOn(sendFileMessageMod, 'sendFileMessage') + .mockImplementationOnce(() => new Promise((_, reject) => (rejectUpload = reject))); + + const sendPromise = shareView.send(); + await Promise.resolve(); + await Promise.resolve(); + shareView.componentWillUnmount(); + (shareView as any).messageComposerRef = { current: null }; + rejectUpload(new Error('upload failed')); + await sendPromise; + + expect(finishShareView).toHaveBeenNthCalledWith(1, '', []); + expect(finishShareView).toHaveBeenNthCalledWith(2, undefined, ['quote-after-send']); + uploadSpy.mockRestore(); + }); + + it('cancelled return restores current input and quote ids when ShareView unmounts', () => { + const shareView = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + const finishShareView = jest.fn(); + (shareView as any).finishShareView = finishShareView; + (shareView as any).messageComposerRef = { current: { getText: () => 'typed caption' } }; + shareView.messageActionStore.getState().actions.setQuoteMessageIds(['quote-1', 'quote-2']); + + shareView.componentWillUnmount(); + + expect(finishShareView).toHaveBeenCalledWith('typed caption', ['quote-1', 'quote-2']); + }); + + it('delays ShareView initialization until the Fabric synchronization timer completes', async () => { + jest.useFakeTimers(); + const shareView = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + const setInput = jest.fn(); + (shareView as any).messageComposerRef = { current: { setInput } }; + (shareView.props.route.params as any).startShareView = jest.fn(() => ({ + text: 'shared text', + selectedMessages: ['quote-1'] + })); + + const initialization = shareView.startShareView(); + await Promise.resolve(); + expect(setInput).not.toHaveBeenCalled(); + jest.advanceTimersByTime(99); + await Promise.resolve(); + expect(setInput).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + await initialization; + + expect(setInput).toHaveBeenCalledWith('shared text'); + expect(shareView.getSelectedMessageIds()).toEqual(['quote-1']); + jest.useRealTimers(); + }); + + it('keeps selected attachment fallback and reload behavior when attachments are removed', () => { + const shareView = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + const first = shareView.state.attachments[0]; + const second = { filename: 'second.jpg', path: '/tmp/second.jpg', size: 1, mime: 'image/jpeg', description: 'second text' }; + const third = { filename: 'third.jpg', path: '/tmp/third.jpg', size: 1, mime: 'image/jpeg', description: 'third text' }; + const fourth = { filename: 'fourth.jpg', path: '/tmp/fourth.jpg', size: 1, mime: 'image/jpeg', description: 'fourth text' }; + shareView.state.attachments.push(second as any, third as any, fourth as any); + shareView.state.selected = second as any; + const setInput = jest.fn(); + const getText = jest.fn(() => 'typed selected text'); + (shareView as any).messageComposerRef = { current: { getText, setInput } }; + + shareView.removeFile(first as any); + expect(shareView.state.attachments.map((attachment: { path?: string }) => attachment.path)).toEqual([ + '/tmp/second.jpg', + '/tmp/third.jpg', + '/tmp/fourth.jpg' + ]); + expect(shareView.state.selected).toBe(second); + expect(setInput).toHaveBeenLastCalledWith('second text'); + expect(getText).not.toHaveBeenCalled(); + + shareView.removeFile(second as any); + expect(shareView.state.selected).toBe(third); + expect(setInput).toHaveBeenLastCalledWith('third text'); + + shareView.state.selected = fourth as any; + shareView.removeFile(fourth as any); + expect(shareView.state.selected).toBe(third); + expect(setInput).toHaveBeenLastCalledWith('third text'); + + shareView.removeFile(third as any); + expect(shareView.state.selected).toEqual({}); + expect(setInput).toHaveBeenLastCalledWith(''); + expect(getText).not.toHaveBeenCalled(); + }); + + it('completes the share extension text-only send', async () => { + const shareView = makeInstance({ mime: 'text/plain', serverVersion: '8.5.0', isShareExtension: true }); + shareView.state.attachments = []; + shareView.state.text = 'shared extension text'; + const sendMessage = require('../../lib/methods/sendMessage').sendMessage as jest.Mock; + let resolveSend!: () => void; + sendMessage.mockImplementationOnce(() => new Promise(resolve => (resolveSend = resolve))); + const sendPromise = shareView.send(); + await Promise.resolve(); + + expect(sendMessage).toHaveBeenCalledWith('room-id', 'shared extension text', '', expect.objectContaining({ id: 'user-id' })); + expect(shareView.state.loading).toBe(true); + expect(shareView.props.dispatch).not.toHaveBeenCalled(); + resolveSend(); + await sendPromise; + expect(shareView.props.dispatch).toHaveBeenCalledWith(appStart({ root: RootEnum.ROOT_INSIDE })); + }); + + it('sends ordinary ShareView text-only content before popping', async () => { + const shareView = makeInstance({ mime: 'text/plain', serverVersion: '8.5.0' }); + shareView.state.attachments = []; + shareView.state.text = 'ordinary shared text'; + const finishShareView = jest.fn(); + (shareView as any).finishShareView = finishShareView; + const sendMessage = require('../../lib/methods/sendMessage').sendMessage as jest.Mock; + + await shareView.send(); + + expect(sendMessage).toHaveBeenCalledWith('room-id', 'ordinary shared text', '', expect.objectContaining({ id: 'user-id' })); + expect(finishShareView).toHaveBeenCalledWith('', []); + expect(shareView.props.navigation.pop).toHaveBeenCalledTimes(1); + }); + + it('bridges real origin media callbacks into ShareView and restores current text and Quotes', async () => { + jest.useFakeTimers(); + const documentPicker = require('expo-document-picker').getDocumentAsync as jest.Mock; + const navigate = require('../../lib/navigation/appNavigation').navigate as jest.Mock; + const getSubscriptionByRoomId = require('../../lib/database/services/Subscription').getSubscriptionByRoomId as jest.Mock; + documentPicker.mockResolvedValue({ + canceled: false, + assets: [{ name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }] + }); + getSubscriptionByRoomId.mockResolvedValue({ + rid: 'room-id', + t: 'c', + roles: [], + update: jest.fn(), + observe: () => ({ subscribe: () => ({ unsubscribe: jest.fn() }) }) + }); + + const originStore = createMessageActionStore(); + act(() => originStore.getState().actions.setQuoteMessageIds(['origin-quote'])); + const originComposerRef = createRef(); + let chooseFile!: () => Promise; + render( + + + + (chooseFile = callback)} /> + + + + ); + await act(async () => { + await Promise.resolve(); + fireEvent.changeText(screen.getByTestId('message-composer-input'), 'origin text'); + }); + await act(async () => { + await chooseFile(); + }); + + const navigationParams = navigate.mock.calls[0][1]; + expect(navigationParams.startShareView().text).toBe('origin text'); + expect(navigationParams.startShareView().selectedMessages).toEqual(['origin-quote']); + + const shareView = makeInstance({ mime: 'application/pdf', serverVersion: '8.3.0' }); + shareView.state.attachments = navigationParams.attachments; + shareView.state.selected = navigationParams.attachments[0]; + (shareView as any).finishShareView = navigationParams.finishShareView; + (shareView as any).props.route.params.startShareView = navigationParams.startShareView; + const shareRender = render({shareView.renderContent()}); + + const initialization = shareView.startShareView(); + await act(async () => { + jest.advanceTimersByTime(100); + await initialization; + }); + act(() => fireEvent.changeText(screen.getByTestId('message-composer-input-share'), 'Share text')); + act(() => { + shareView.messageActionStore.getState().actions.setQuoteMessageIds(['share-quote']); + shareView.componentWillUnmount(); + }); + + expect(originComposerRef.current?.getText()).toBe('Share text'); + expect(originStore.getState().action).toEqual({ kind: 'quote', messageIds: ['share-quote'] }); + act(() => shareRender.unmount()); + jest.useRealTimers(); + }); + + it.each(['success', 'failure'] as const)('bridges real callbacks through ShareView send %s', async outcome => { + jest.useFakeTimers(); + const documentPicker = require('expo-document-picker').getDocumentAsync as jest.Mock; + const navigate = require('../../lib/navigation/appNavigation').navigate as jest.Mock; + documentPicker.mockResolvedValue({ + canceled: false, + assets: [{ name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }] + }); + + const originStore = createMessageActionStore(); + act(() => originStore.getState().actions.setQuoteMessageIds(['origin-quote'])); + const originComposerRef = createRef(); + let chooseFile!: () => Promise; + render( + + + + (chooseFile = callback)} /> + + + + ); + await act(async () => { + await Promise.resolve(); + fireEvent.changeText(screen.getByTestId('message-composer-input'), 'origin text'); + }); + await act(async () => { + await chooseFile(); + }); + const navigationParams = navigate.mock.calls[0][1]; + + const shareView = makeInstance({ mime: 'application/pdf', serverVersion: '8.3.0' }); + shareView.state.attachments = navigationParams.attachments.map((attachment: any) => ({ ...attachment, canUpload: true })); + shareView.state.selected = shareView.state.attachments[0]; + (shareView as any).finishShareView = navigationParams.finishShareView; + const shareRender = render({shareView.renderContent()}); + act(() => { + fireEvent.changeText(screen.getByTestId('message-composer-input-share'), 'Share text'); + shareView.messageActionStore.getState().actions.setQuoteMessageIds(['share-quote']); + }); + + let completeUpload!: () => void; + let failUpload!: (error: Error) => void; + const sendFileMessageMod = require('../../lib/methods/sendFileMessage'); + const uploadSpy = jest.spyOn(sendFileMessageMod, 'sendFileMessage').mockImplementationOnce( + () => + new Promise((resolve, reject) => { + completeUpload = resolve; + failUpload = reject; + }) + ); + fireEvent.press(screen.getByTestId('message-composer-send')); + await Promise.resolve(); + await Promise.resolve(); + + expect(originComposerRef.current?.getText()).toBe(''); + expect(originStore.getState().action).toBeNull(); + expect(shareView.props.navigation.pop).toHaveBeenCalledTimes(1); + await act(async () => { + shareRender.unmount(); + (shareView as any).messageComposerRef = { current: null }; + shareView.componentWillUnmount(); + await Promise.resolve(); + }); + + if (outcome === 'success') { + await act(async () => { + completeUpload(); + await Promise.resolve(); + }); + } else { + await act(async () => { + failUpload(new Error('upload failed')); + await Promise.resolve(); + }); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(originComposerRef.current?.getText()).toBe(''); + expect(shareView.getSelectedMessageIds()).toEqual(['share-quote']); + const expectedOriginAction = outcome === 'failure' ? { kind: 'quote', messageIds: ['share-quote'] } : null; + expect(originStore.getState().action).toEqual(expectedOriginAction); + uploadSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('saves and restores selected attachment text through the rendered composer', () => { + const view = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + const first = view.state.attachments[0]; + const second = { filename: 'second.jpg', path: '/tmp/second.jpg', size: 1, mime: 'image/jpeg', description: 'saved second' }; + view.state.attachments.push(second as any); + render({view.renderContent()}); + act(() => fireEvent.changeText(screen.getByTestId('message-composer-input-share'), 'first caption')); + + act(() => view.selectFile(second as any)); + expect(first.description).toBe('first caption'); + expect((view as any).messageComposerRef.current?.getText()).toBe('saved second'); + act(() => fireEvent.changeText(screen.getByTestId('message-composer-input-share'), 'second caption')); + + act(() => view.selectFile(first as any)); + expect((view as any).messageComposerRef.current?.getText()).toBe('first caption'); + act(() => view.selectFile(second as any)); + expect((view as any).messageComposerRef.current?.getText()).toBe('second caption'); + }); + + it('flushes selected attachment text through the rendered composer before sending', async () => { + const view = makeInstance({ mime: 'image/jpeg', serverVersion: '8.5.0' }); + view.state.attachments[0].canUpload = true; + const attachment = view.state.attachments[0]; + (view as any).finishShareView = jest.fn(); + act(() => view.messageActionStore.getState().actions.setQuoteMessageIds(['quote-1'])); + + render({view.renderContent()}); + const input = screen.getByTestId('message-composer-input-share'); + act(() => fireEvent.changeText(input, 'caption before send')); + + const sendFileMessageMod = require('../../lib/methods/sendFileMessage'); + const uploadSpy = jest.spyOn(sendFileMessageMod, 'sendFileMessage').mockResolvedValueOnce(undefined); + const prepareQuoteMessageMod = require('../../containers/MessageComposer/helpers/prepareQuoteMessage'); + const prepareSpy = jest.spyOn(prepareQuoteMessageMod, 'prepareQuoteMessage').mockResolvedValueOnce('quoted text'); + await act(async () => { + fireEvent.press(screen.getByTestId('message-composer-send')); + await Promise.resolve(); + }); + + expect(attachment.description).toBe('caption before send'); + expect(prepareSpy).toHaveBeenCalledWith('', ['quote-1']); + expect((view as any).finishShareView).toHaveBeenCalledWith('', []); + uploadSpy.mockRestore(); + prepareSpy.mockRestore(); + }); }); diff --git a/app/views/ShareView/index.tsx b/app/views/ShareView/index.tsx index 229528c6910..58c1929b9c5 100644 --- a/app/views/ShareView/index.tsx +++ b/app/views/ShareView/index.tsx @@ -7,7 +7,7 @@ import { Q } from '@nozbe/watermelondb'; import { type Dispatch } from 'redux'; import { compareServerVersion } from '../../lib/methods/helpers/compareServerVersion'; -import { type IMessageComposerRef, MessageComposerContainer } from '../../containers/MessageComposer'; +import { type IMessageComposerRef, ComposerProvider, MessageComposerContainer } from '../../containers/MessageComposer'; import { type InsideStackParamList } from '../../stacks/types'; import { themes } from '../../lib/constants/colors'; import I18n from '../../i18n'; @@ -37,8 +37,11 @@ import { import { sendAttachments } from '../../lib/methods/sendFileMessage/sendAttachments'; import { sendMessage } from '../../lib/methods/sendMessage'; import { hasPermission, isAndroid, canUploadFile, isReadOnly, isBlocked } from '../../lib/methods/helpers'; -import { RoomProviders } from '../RoomView/components/RoomProviders'; -import { createMessageActionStore, type TMessageActionStore } from '../../containers/message/stores/MessageActionStore'; +import { + createMessageActionStore, + MessageActionProvider, + type TMessageActionStore +} from '../../containers/message/stores/MessageActionStore'; import { appStart } from '../../actions/app'; interface IShareViewState { @@ -389,28 +392,29 @@ class ShareView extends Component { if (attachments.length) { return ( - - - - - - - - + + + + + + + + + + ); } From 6a936c223519d229aae3c1a7f8ed8b39296d190a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 14:57:22 -0300 Subject: [PATCH 2/6] test: seed message quotes through the restoration API --- app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx index 27b391119a5..b5fa3ece4b5 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useRoomMessaging.test.tsx @@ -90,7 +90,7 @@ describe('useRoomMessaging', () => { ); - act(() => result.current.messageActionStore.getState().actions.startQuote('quoted-message')); + act(() => result.current.messageActionStore.getState().actions.setQuoteMessageIds(['quoted-message'])); expect(actionProbe.getByTestId('action').props.children).toBe('quote'); let sendPromise: void; From ee2890255d32d389793be7cffe5e1c6129e632f5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 16:34:58 -0300 Subject: [PATCH 3/6] test: obtain chooseFile through renderHook in the ShareView bridge tests --- app/views/ShareView/ShareView.test.tsx | 49 ++++++++++---------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/app/views/ShareView/ShareView.test.tsx b/app/views/ShareView/ShareView.test.tsx index a1212db1740..ac07dae2a86 100644 --- a/app/views/ShareView/ShareView.test.tsx +++ b/app/views/ShareView/ShareView.test.tsx @@ -1,5 +1,5 @@ -import { createRef, useEffect, type ReactElement } from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { createRef, type ReactElement, type RefObject } from 'react'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { initStore } from '../../lib/store/auxStore'; @@ -8,7 +8,7 @@ import { appStart } from '../../actions/app'; import { RootEnum } from '../../definitions'; import { RoomProviders } from '../RoomView/components/RoomProviders'; import { MessageComposerContainer, type IMessageComposerRef } from '../../containers/MessageComposer'; -import { createMessageActionStore } from '../../containers/message/stores/MessageActionStore'; +import { createMessageActionStore, type TMessageActionStore } from '../../containers/message/stores/MessageActionStore'; import { useChooseMedia } from '../../containers/MessageComposer/hooks/useChooseMedia'; jest.mock('expo-document-picker', () => ({ @@ -64,11 +64,16 @@ const mockGetSubscriptionByRoomId = require('../../lib/database/services/Subscri initStore(mockedStore); -const OriginMediaProbe = ({ onReady }: { onReady: (chooseFile: () => Promise) => void }): ReactElement | null => { - const { chooseFile } = useChooseMedia({ rid: 'room-id', tmid: undefined, permissionToUpload: true }); - useEffect(() => onReady(chooseFile), [chooseFile, onReady]); - return null; -}; +const renderOriginChooseMedia = (originStore: TMessageActionStore, originComposerRef: RefObject) => + renderHook(() => useChooseMedia({ rid: 'room-id', tmid: undefined, permissionToUpload: true }), { + wrapper: ({ children }: { children: ReactElement }) => ( + + + {children} + + + ) + }); const makeInstance = ({ mime, @@ -430,23 +435,15 @@ describe('ShareView', () => { const originStore = createMessageActionStore(); act(() => originStore.getState().actions.setQuoteMessageIds(['origin-quote'])); const originComposerRef = createRef(); - let chooseFile!: () => Promise; - render( - - - - (chooseFile = callback)} /> - - - - ); + const { result } = renderOriginChooseMedia(originStore, originComposerRef); await act(async () => { await Promise.resolve(); fireEvent.changeText(screen.getByTestId('message-composer-input'), 'origin text'); }); await act(async () => { - await chooseFile(); + await result.current.chooseFile(); }); + expect(documentPicker).toHaveBeenCalledTimes(1); const navigationParams = navigate.mock.calls[0][1]; expect(navigationParams.startShareView().text).toBe('origin text'); @@ -488,23 +485,15 @@ describe('ShareView', () => { const originStore = createMessageActionStore(); act(() => originStore.getState().actions.setQuoteMessageIds(['origin-quote'])); const originComposerRef = createRef(); - let chooseFile!: () => Promise; - render( - - - - (chooseFile = callback)} /> - - - - ); + const { result } = renderOriginChooseMedia(originStore, originComposerRef); await act(async () => { await Promise.resolve(); fireEvent.changeText(screen.getByTestId('message-composer-input'), 'origin text'); }); await act(async () => { - await chooseFile(); + await result.current.chooseFile(); }); + expect(documentPicker).toHaveBeenCalledTimes(1); const navigationParams = navigate.mock.calls[0][1]; const shareView = makeInstance({ mime: 'application/pdf', serverVersion: '8.3.0' }); From 5d587405b6d7e320fc543102669dfb8ff8b21cf0 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 17:30:42 -0300 Subject: [PATCH 4/6] refactor: share room contract across composer and screens (#7660) * refactor: share room contract across composer and screens * refactor: constrain room update patches to observed fields * refactor: name the observed room fields for what they are --- .../MessageComposer/ComposerStore.tsx | 32 ++--------- .../components/ComposerInput.test.tsx | 30 ++++------ .../components/ComposerInput.tsx | 2 +- app/definitions/TRoom.ts | 55 +++++++++++++++++++ .../useRoomWithUpdateFromStore.test.tsx | 26 +++++++++ app/lib/hooks/useRoomWithUpdateFromStore.ts | 14 +++++ app/lib/methods/helpers/isReadOnly.ts | 4 +- app/lib/methods/helpers/room.ts | 4 +- .../RoomView/__tests__/RoomGate.test.tsx | 11 ++-- .../RoomView/components/RoomMessageList.tsx | 5 +- app/views/RoomView/constants.test.ts | 8 +-- app/views/RoomView/constants.ts | 38 +------------ app/views/RoomView/definitions.ts | 31 +++-------- .../hooks/__tests__/useCloseBanner.test.ts | 8 +-- .../hooks/__tests__/useRoomRemoved.test.ts | 4 +- app/views/RoomView/hooks/useCloseBanner.ts | 4 +- app/views/RoomView/hooks/useE2EEStatus.ts | 2 +- app/views/RoomView/hooks/useHeader.tsx | 5 +- app/views/RoomView/index.tsx | 2 +- .../services/__tests__/joinRoom.test.ts | 4 +- app/views/RoomView/services/joinRoom.ts | 7 ++- app/views/RoomView/services/parseRoomRoute.ts | 5 +- app/views/RoomView/stores/RoomStore.ts | 21 +++---- .../RoomView/stores/RoomStoreContext.tsx | 15 ++--- .../stores/__tests__/RoomStore.test.ts | 7 ++- app/views/ShareView/Header.tsx | 5 +- app/views/ShareView/index.tsx | 6 +- 27 files changed, 185 insertions(+), 170 deletions(-) create mode 100644 app/definitions/TRoom.ts create mode 100644 app/lib/hooks/__tests__/useRoomWithUpdateFromStore.test.tsx create mode 100644 app/lib/hooks/useRoomWithUpdateFromStore.ts diff --git a/app/containers/MessageComposer/ComposerStore.tsx b/app/containers/MessageComposer/ComposerStore.tsx index 981f2b9f252..3d67032eeb9 100644 --- a/app/containers/MessageComposer/ComposerStore.tsx +++ b/app/containers/MessageComposer/ComposerStore.tsx @@ -2,37 +2,15 @@ import { createContext, useContext, useEffect, useState, type ReactElement, type import { createStore, useStore } from 'zustand'; import { type StoreApi } from 'zustand'; -import { - type IMessage, - type IMessageEditAttachment, - type ILastMessage, - type IVisitor, - type TSubscriptionModel -} from '../../definitions'; -import { useRoomWithUpdateFromStore } from '../../views/RoomView/stores/RoomStoreContext'; +import { type IMessage, type IMessageEditAttachment } from '../../definitions'; +import { type IRoomWithUpdateState, useRoomWithUpdateFromStore } from '../../lib/hooks/useRoomWithUpdateFromStore'; +import { type TRoomOrPreview } from '../../definitions/TRoom'; -type ComposerRoom = - | TSubscriptionModel - | { - rid: string; - t: string; - name?: string; - fname?: string; - prid?: string; - visitor?: IVisitor; - joinCodeRequired?: boolean; - status?: string; - lastMessage?: ILastMessage; - sysMes?: boolean; - onHold?: boolean; - }; - -export type ComposerState = { +export type ComposerState = IRoomWithUpdateState & { + room: TRoomOrPreview; rid?: string; t?: string; tmid?: string; - room: ComposerRoom; - roomUpdate?: Partial; sharing?: boolean; isAutocompleteVisible: boolean; editCancel?: () => void; diff --git a/app/containers/MessageComposer/components/ComposerInput.test.tsx b/app/containers/MessageComposer/components/ComposerInput.test.tsx index 996d9a9d911..e768486a03a 100644 --- a/app/containers/MessageComposer/components/ComposerInput.test.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.test.tsx @@ -1,12 +1,10 @@ import { createRef } from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { Text } from 'react-native'; -import { createStore } from 'zustand'; import { ComposerInput } from './ComposerInput'; import { MessageComposerProvider, useAutocompleteParams } from '../context'; import { ComposerProvider } from '../ComposerStore'; -import { RoomStoreContext } from '../../../views/RoomView/stores/RoomStoreContext'; import { createMessageActionStore, MessageActionProvider } from '../../message/stores/MessageActionStore'; import { type IComposerInput } from '../interfaces'; import { loadDraftMessage } from '../../../lib/methods/draftMessage'; @@ -40,6 +38,7 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('../../../lib/hooks/useAltTextSupported', () => ({ useAltTextSupported: jest.fn(() => false) })); jest.mock('../../../lib/hooks/useMasterDetail', () => ({ useMasterDetail: jest.fn(() => false) })); +jest.mock('../../../lib/methods/helpers/helpers', () => ({ getRoomTitle: jest.fn(() => 'Room') })); jest.mock('../../../lib/methods/helpers/externalInput', () => ({ isExternalKeyboardConnected: jest.fn(() => false) })); jest.mock('../hooks/useIOSBackSwipeHandler', () => ({ __esModule: true, @@ -59,17 +58,11 @@ const composerState = { rid: 'room-1', t: 'c', tmid: undefined, - room: undefined, + room: { rid: 'room-1', t: 'c' }, sharing: false, onRemoveQuoteMessage: jest.fn() }; -const createRoomStore = () => - createStore()(() => ({ - room: composerState.room, - roomUpdate: undefined - })); - const renderInput = ({ action, sharing = false @@ -79,20 +72,17 @@ const renderInput = ({ } = {}) => { const composerRef = createRef(); const inputRef = createRef(); - const roomStore = createRoomStore(); const messageActionStore = createMessageActionStore(action); const tree = ( - - - - <> - - - - - - + + + <> + + + + + ); const rendered = render(tree); diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 04d5ee89afc..81b3bc1da66 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -67,7 +67,7 @@ export const ComposerInput = memo( const isMasterDetail = useMasterDetail(); const altTextSupported = useAltTextSupported(); let placeholder = tmid ? I18n.t('Add_thread_reply') : ''; - if (room && !tmid) { + if (!tmid) { placeholder = I18n.t('Message_roomname', { roomName: (room.t === 'd' ? '@' : '#') + getRoomTitle(room) }); if (!isTablet && placeholder.length > COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH) { placeholder = `${placeholder.slice(0, COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH)}...`; diff --git a/app/definitions/TRoom.ts b/app/definitions/TRoom.ts new file mode 100644 index 00000000000..6c4c1b7ab84 --- /dev/null +++ b/app/definitions/TRoom.ts @@ -0,0 +1,55 @@ +import { type ILastMessage } from './IMessage'; +import { type ISubscription, type IVisitor, type TSubscriptionModel } from './ISubscription'; + +export type TPreviewRoom = { + rid: string; + t: string; + name?: string; + fname?: string; + prid?: string; + visitor?: IVisitor; + joinCodeRequired?: boolean; + status?: string; + lastMessage?: ILastMessage; + sysMes?: boolean; + onHold?: boolean; +}; + +export type TRoomOrPreview = TSubscriptionModel | TPreviewRoom; + +export const roomObservedFields = [ + 'f', + 'ro', + 'blocked', + 'blocker', + 'archived', + 'tunread', + 'tunreadUser', + 'tunreadGroup', + 'muted', + 'ignored', + 'jitsiTimeout', + 'announcement', + 'sysMes', + 'topic', + 'name', + 'fname', + 'roles', + 'bannerClosed', + 'visitor', + 'joinCodeRequired', + 'teamMain', + 'teamId', + 'status', + 'onHold', + 't', + 'autoTranslate', + 'autoTranslateLanguage', + 'unmuted', + 'E2EKey', + 'encrypted', + 'inviter' +] as const satisfies readonly (keyof ISubscription)[]; + +export type TRoomObservedField = (typeof roomObservedFields)[number]; +export type TRoomObservedFields = Partial>; diff --git a/app/lib/hooks/__tests__/useRoomWithUpdateFromStore.test.tsx b/app/lib/hooks/__tests__/useRoomWithUpdateFromStore.test.tsx new file mode 100644 index 00000000000..7eb61f2dfd3 --- /dev/null +++ b/app/lib/hooks/__tests__/useRoomWithUpdateFromStore.test.tsx @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { createStore } from 'zustand'; + +import { type TRoomOrPreview } from '../../../definitions/TRoom'; +import { useRoomWithUpdateFromStore, type IRoomWithUpdateState } from '../useRoomWithUpdateFromStore'; + +describe('useRoomWithUpdateFromStore', () => { + it('re-renders when an observed room mutates in place and receives a new patch', () => { + const room: TRoomOrPreview = { rid: 'rid-1', t: 'c', name: 'old' }; + const store = createStore()(() => ({ room, roomUpdate: {} })); + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useRoomWithUpdateFromStore(store); + }); + + act(() => { + room.name = 'new'; + store.setState({ roomUpdate: { name: 'new' } }); + }); + + expect(result.current).toBe(room); + expect(result.current.name).toBe('new'); + expect(renders).toBe(2); + }); +}); diff --git a/app/lib/hooks/useRoomWithUpdateFromStore.ts b/app/lib/hooks/useRoomWithUpdateFromStore.ts new file mode 100644 index 00000000000..7d002859841 --- /dev/null +++ b/app/lib/hooks/useRoomWithUpdateFromStore.ts @@ -0,0 +1,14 @@ +import { useStore } from 'zustand'; +import { type StoreApi } from 'zustand'; + +import { type TRoomOrPreview, type TRoomObservedFields } from '../../definitions/TRoom'; + +export interface IRoomWithUpdateState { + room: TRoomOrPreview; + roomUpdate?: TRoomObservedFields; +} + +export const useRoomWithUpdateFromStore = (store: StoreApi): S['room'] => { + useStore(store, s => s.roomUpdate); + return useStore(store, s => s.room); +}; diff --git a/app/lib/methods/helpers/isReadOnly.ts b/app/lib/methods/helpers/isReadOnly.ts index 1dca9bb578b..524fcd5930b 100644 --- a/app/lib/methods/helpers/isReadOnly.ts +++ b/app/lib/methods/helpers/isReadOnly.ts @@ -2,6 +2,8 @@ import { store as reduxStore } from '../../store/auxStore'; import { type ISubscription } from '../../../definitions'; import { hasPermission } from './helpers'; +type ReadOnlyRoom = Pick, 'rid' | 'archived' | 'muted' | 'ro' | 'unmuted'>; + const canPostReadOnly = async (room: Partial, username?: string) => { // RC 6.4.0 const isUnmuted = !!room?.unmuted?.find(m => m === username); @@ -27,7 +29,7 @@ const evaluateReadOnly = (room: Partial, username: string | undef return false; }; -export const isReadOnly = async (room: Partial, username?: string): Promise => { +export const isReadOnly = async (room: ReadOnlyRoom, username?: string): Promise => { if (room.archived || isMuted(room, username)) { return true; } diff --git a/app/lib/methods/helpers/room.ts b/app/lib/methods/helpers/room.ts index 9f270c3c4e4..d33eca7e7f8 100644 --- a/app/lib/methods/helpers/room.ts +++ b/app/lib/methods/helpers/room.ts @@ -1,10 +1,10 @@ import dayjs from '../../dayjs'; import { themes } from '../../constants/colors'; import I18n from '../../../i18n'; -import { type IAttachment, SubscriptionType, type TSubscriptionModel } from '../../../definitions'; +import { type IAttachment, SubscriptionType } from '../../../definitions'; import { type TSupportedThemes } from '../../../theme'; -export const isBlocked = (room: TSubscriptionModel): boolean => { +export const isBlocked = (room: { t?: string; blocked?: boolean; blocker?: boolean }): boolean => { if (room) { const { t, blocked, blocker } = room; if (t === SubscriptionType.DIRECT && (blocked || blocker)) { diff --git a/app/views/RoomView/__tests__/RoomGate.test.tsx b/app/views/RoomView/__tests__/RoomGate.test.tsx index 6b0a1c07b62..90fb61d8430 100644 --- a/app/views/RoomView/__tests__/RoomGate.test.tsx +++ b/app/views/RoomView/__tests__/RoomGate.test.tsx @@ -4,7 +4,8 @@ import { Provider } from 'react-redux'; import { createStore as createReduxStore } from 'redux'; import RoomGate from '../index'; -import { type IRoomViewProps, type RoomState } from '../definitions'; +import { type IRoomViewProps } from '../definitions'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; import { isInviteSubscription } from '../../../lib/methods/isInviteSubscription'; import { useE2EEStatus } from '../hooks/useE2EEStatus'; @@ -40,7 +41,7 @@ jest.mock('../hooks/useE2EEStatus', () => ({ 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' } }; +const room: { current: TRoomOrPreview } = { current: { rid: 'rid-1', t: 'c' } }; jest.mock('../stores/RoomStore', () => { const { createStore } = require('zustand'); @@ -99,7 +100,7 @@ describe('RoomGate', () => { }); 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']; + room.current = { id: 'sub-1', rid: 'rid-1', t: 'c' } as TRoomOrPreview; jest.mocked(isInviteSubscription).mockReturnValue(true); renderGate(); @@ -109,7 +110,7 @@ describe('RoomGate', () => { }); it('keeps the room screen unmounted while the E2EE key is missing', () => { - room.current = { rid: 'rid-1', t: 'c', encrypted: true } as RoomState['room']; + room.current = { rid: 'rid-1', t: 'c', encrypted: true } as TRoomOrPreview; jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: true, showE2EEDisabledRoom: false }); renderGate(); @@ -119,7 +120,7 @@ describe('RoomGate', () => { }); it('keeps the room screen unmounted while the session has E2EE disabled', () => { - room.current = { rid: 'rid-1', t: 'c', encrypted: true } as RoomState['room']; + room.current = { rid: 'rid-1', t: 'c', encrypted: true } as TRoomOrPreview; jest.mocked(useE2EEStatus).mockReturnValue({ showMissingE2EEKey: false, showE2EEDisabledRoom: true }); renderGate(); diff --git a/app/views/RoomView/components/RoomMessageList.tsx b/app/views/RoomView/components/RoomMessageList.tsx index 0b13ac753f5..3adf7cfbbae 100644 --- a/app/views/RoomView/components/RoomMessageList.tsx +++ b/app/views/RoomView/components/RoomMessageList.tsx @@ -4,7 +4,8 @@ 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 { type IRoomMessageListProps } from '../definitions'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; import { useRoomStore, useRoomWithUpdate } from '../stores/RoomStoreContext'; import List from '../List'; import { RoomMessageProvider } from './RoomMessageProvider'; @@ -12,7 +13,7 @@ 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 getHideSystemMessages = (room: TRoomOrPreview, Hide_System_Messages?: string[]): string[] => { const { sysMes } = room; if (Array.isArray(sysMes)) { return sysMes; diff --git a/app/views/RoomView/constants.test.ts b/app/views/RoomView/constants.test.ts index 5eb6c775a11..d005b3ca29a 100644 --- a/app/views/RoomView/constants.test.ts +++ b/app/views/RoomView/constants.test.ts @@ -1,11 +1,11 @@ -import { roomAttrsUpdate } from './constants'; +import { roomObservedFields } from '../../definitions/TRoom'; -describe('roomAttrsUpdate invariant', () => { +describe('roomObservedFields 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 + // reactive while these columns are observed. Dropping one of these columns from roomObservedFields // silently breaks that reactivity at runtime with no other failing signal — this test is the // only guard. it.each(['roles', 'encrypted', 'E2EKey'])('keeps %s so read-time derivations stay reactive', key => { - expect(roomAttrsUpdate).toContain(key); + expect(roomObservedFields).toContain(key); }); }); diff --git a/app/views/RoomView/constants.ts b/app/views/RoomView/constants.ts index 7136a340bfd..52a347d7e00 100644 --- a/app/views/RoomView/constants.ts +++ b/app/views/RoomView/constants.ts @@ -1,40 +1,6 @@ -import { type TRoomUpdate } from './definitions'; +import { type TRoomObservedField } from '../../definitions/TRoom'; -export const roomAttrsUpdate = [ - 'f', - 'ro', - 'blocked', - 'blocker', - 'archived', - 'tunread', - 'tunreadUser', - 'tunreadGroup', - 'muted', - 'ignored', - 'jitsiTimeout', - 'announcement', - 'sysMes', - 'topic', - 'name', - 'fname', - 'roles', - 'bannerClosed', - 'visitor', - 'joinCodeRequired', - 'teamMain', - 'teamId', - 'status', - 'onHold', - 't', - 'autoTranslate', - 'autoTranslateLanguage', - 'unmuted', - 'E2EKey', - 'encrypted', - 'inviter' -] as const satisfies readonly TRoomUpdate[]; - -export const roomAttrsUpdateColumns: Record<(typeof roomAttrsUpdate)[number], string> = { +export const roomObservedColumns: Record = { f: 'f', ro: 'ro', blocked: 'blocked', diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index c5f32680940..92d16e8bfa7 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -7,15 +7,14 @@ import { type ChatsStackParamList } from '../../stacks/types'; import { type IBaseScreen, type IEmoji, - type ILastMessage, type IMessage, type IMessageEditAttachment, - type IVisitor, type RoomType, type TAnyMessageModel, - type TSubscriptionModel, type IUseRoomMessageHandlersResult } from '../../definitions'; +import { type TRoomOrPreview, type TRoomObservedFields } from '../../definitions/TRoom'; +import { type TSubscriptionModel } from '../../definitions/ISubscription'; import { type TActionSheetOptions } from '../../containers/ActionSheet'; import { type IMessageComposerRef } from '../../containers/MessageComposer/interfaces'; import { type IMessageActions, type IMessageActionsProps } from '../../containers/MessageActions'; @@ -30,7 +29,7 @@ export interface IRoomScreenInput { t: string; tmid?: string; name?: string; - initialRoom: IRoomViewState['room']; + initialRoom: TRoomOrPreview; roomUserId?: string | null; } @@ -52,25 +51,9 @@ export interface IFooterPreviewProps { message: string; } -export type TRoomUpdate = keyof TSubscriptionModel; - export interface IRoomViewState { - room: - | TSubscriptionModel - | { - rid: string; - t: string; - name?: string; - fname?: string; - prid?: string; - visitor?: IVisitor; - joinCodeRequired?: boolean; - status?: string; - lastMessage?: ILastMessage; - sysMes?: boolean; - onHold?: boolean; - }; - roomUpdate: Partial>; + room: TRoomOrPreview; + roomUpdate: TRoomObservedFields; member: any; lastSeen: Date | null; } @@ -147,8 +130,8 @@ export type TRoomInitResult = | { status: 'failed' }; export interface RoomState { - room: IRoomViewState['room']; - roomUpdate: IRoomViewState['roomUpdate']; + room: TRoomOrPreview; + roomUpdate: TRoomObservedFields; joined: boolean; subscribed: boolean; member: IRoomViewState['member']; diff --git a/app/views/RoomView/hooks/__tests__/useCloseBanner.test.ts b/app/views/RoomView/hooks/__tests__/useCloseBanner.test.ts index 289e91e0e9f..73000299bf1 100644 --- a/app/views/RoomView/hooks/__tests__/useCloseBanner.test.ts +++ b/app/views/RoomView/hooks/__tests__/useCloseBanner.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react-native'; -import { type IRoomViewState } from '../../definitions'; +import { type TRoomOrPreview } from '../../../../definitions/TRoom'; import { useCloseBanner } from '../useCloseBanner'; const mockWrite = jest.fn((fn: () => Promise) => fn()); @@ -20,7 +20,7 @@ describe('useCloseBanner', () => { mutator(draft); return draft; }); - const room = { id: 'room-1', update } as unknown as IRoomViewState['room']; + const room = { id: 'room-1', update } as unknown as TRoomOrPreview; const { result } = renderHook(() => useCloseBanner(room)); await result.current(); @@ -30,7 +30,7 @@ describe('useCloseBanner', () => { }); it('is a no-op for a room without a database identity', async () => { - const room = { rid: 'rid-1', t: 'c' } as IRoomViewState['room']; + const room = { rid: 'rid-1', t: 'c' } as TRoomOrPreview; const { result } = renderHook(() => useCloseBanner(room)); await result.current(); @@ -40,7 +40,7 @@ describe('useCloseBanner', () => { it('swallows write errors', async () => { mockWrite.mockRejectedValueOnce(new Error('boom')); - const room = { id: 'room-1', update: jest.fn() } as unknown as IRoomViewState['room']; + const room = { id: 'room-1', update: jest.fn() } as unknown as TRoomOrPreview; const { result } = renderHook(() => useCloseBanner(room)); await expect(result.current()).resolves.toBeUndefined(); diff --git a/app/views/RoomView/hooks/__tests__/useRoomRemoved.test.ts b/app/views/RoomView/hooks/__tests__/useRoomRemoved.test.ts index 9c3e6620345..9df0a623297 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomRemoved.test.ts +++ b/app/views/RoomView/hooks/__tests__/useRoomRemoved.test.ts @@ -5,7 +5,7 @@ import I18n from '../../../../i18n'; import EventEmitterReal from '../../../../lib/methods/helpers/events'; import Navigation from '../../../../lib/navigation/appNavigation'; import { showErrorAlert } from '../../../../lib/methods/helpers/info'; -import { type IRoomViewState } from '../../definitions'; +import { type TRoomOrPreview } from '../../../../definitions/TRoom'; import { useRoomRemoved } from '../useRoomRemoved'; jest.mock('../../../../lib/methods/helpers', () => ({ getRoomTitle: jest.fn(() => 'Room') })); @@ -15,7 +15,7 @@ jest.mock('../../../../lib/methods/helpers/info', () => ({ showErrorAlert: jest. const mockPopToTop = Navigation.popToTop as jest.Mock; const mockShowErrorAlert = showErrorAlert as jest.Mock; -const renderRoomRemoved = (rid: string | undefined, isMasterDetail: boolean, room: IRoomViewState['room']) => { +const renderRoomRemoved = (rid: string | undefined, isMasterDetail: boolean, room: TRoomOrPreview) => { return renderHook(() => useRoomRemoved(rid, isMasterDetail, createStore(() => ({ room })) as any)); }; diff --git a/app/views/RoomView/hooks/useCloseBanner.ts b/app/views/RoomView/hooks/useCloseBanner.ts index b0492ecb6d7..ee3d3b40e0c 100644 --- a/app/views/RoomView/hooks/useCloseBanner.ts +++ b/app/views/RoomView/hooks/useCloseBanner.ts @@ -1,7 +1,7 @@ import database from '../../../lib/database'; -import { type IRoomViewState } from '../definitions'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; -export function useCloseBanner(room: IRoomViewState['room']): () => Promise { +export function useCloseBanner(room: TRoomOrPreview): () => Promise { return async () => { if ('id' in room) { try { diff --git a/app/views/RoomView/hooks/useE2EEStatus.ts b/app/views/RoomView/hooks/useE2EEStatus.ts index d85894822ee..79e29cad47f 100644 --- a/app/views/RoomView/hooks/useE2EEStatus.ts +++ b/app/views/RoomView/hooks/useE2EEStatus.ts @@ -2,7 +2,7 @@ import { isE2EEDisabledEncryptedRoom, isMissingRoomE2EEKey } from '../../../lib/ import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { type IUseE2EEStatusResult } from '../definitions'; import { type RoomStore } from '../definitions'; -import { useRoomWithUpdateFromStore } from '../stores/RoomStoreContext'; +import { useRoomWithUpdateFromStore } from '../../../lib/hooks/useRoomWithUpdateFromStore'; export const useE2EEStatus = (roomStore: RoomStore): IUseE2EEStatusResult => { const encryptionEnabled = useAppSelector(state => state.encryption.enabled); diff --git a/app/views/RoomView/hooks/useHeader.tsx b/app/views/RoomView/hooks/useHeader.tsx index b1ed36c3cf5..d7bb0bb13d3 100644 --- a/app/views/RoomView/hooks/useHeader.tsx +++ b/app/views/RoomView/hooks/useHeader.tsx @@ -10,7 +10,8 @@ import { isInviteSubscription } from '../../../lib/methods/isInviteSubscription' import { type IOmnichannelSource, type ISubscription, type IVisitor } from '../../../definitions'; import LeftButtons from '../components/LeftButtons'; import RightButtons from '../components/RightButtons'; -import { type IRoomViewProps, type IRoomViewState } from '../definitions'; +import { type IRoomViewProps } from '../definitions'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; import { type RoomStore } from '../definitions'; import { useGoRoomActionsView } from './useGoRoomActionsView'; @@ -23,7 +24,7 @@ interface IUseHeaderParams { } interface IGetRoomHeaderPropsParams { - room: IRoomViewState['room']; + room: TRoomOrPreview; tmid?: string; roomName?: string; roomUserId?: string | null; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index c2436ca28d1..1ea21739207 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -11,7 +11,7 @@ import RoomScreen from './RoomScreen'; import { parseRoomRoute } from './services/parseRoomRoute'; import { createRoomStore, observeRoom } from './stores/RoomStore'; import { type RoomStore } from './definitions'; -import { useRoomWithUpdateFromStore } from './stores/RoomStoreContext'; +import { useRoomWithUpdateFromStore } from '../../lib/hooks/useRoomWithUpdateFromStore'; import { useE2EEStatus } from './hooks/useE2EEStatus'; import { useHeader } from './hooks/useHeader'; diff --git a/app/views/RoomView/services/__tests__/joinRoom.test.ts b/app/views/RoomView/services/__tests__/joinRoom.test.ts index edf1082c9ae..52c2bcc5791 100644 --- a/app/views/RoomView/services/__tests__/joinRoom.test.ts +++ b/app/views/RoomView/services/__tests__/joinRoom.test.ts @@ -1,6 +1,6 @@ import { joinRoom as joinRoomService } from '../../../../lib/services/restApi'; import { takeInquiry, takeResume } from '../../../../ee/omnichannel/lib'; -import { type IRoomViewState } from '../../definitions'; +import { type TRoomOrPreview } from '../../../../definitions/TRoom'; import { createRoomStore } from '../../stores/RoomStore'; jest.mock('../../../../lib/database', () => ({ @@ -45,7 +45,7 @@ const mockJoinRoomService = joinRoomService as jest.Mock; const mockTakeInquiry = takeInquiry as jest.Mock; const mockTakeResume = takeResume as jest.Mock; -const makeStore = (room: IRoomViewState['room']) => { +const makeStore = (room: TRoomOrPreview) => { const store = createRoomStore({ initialRoom: room }); store.setState({ join: jest.fn() }); return store; diff --git a/app/views/RoomView/services/joinRoom.ts b/app/views/RoomView/services/joinRoom.ts index f3b7dbe4343..30d17e1443d 100644 --- a/app/views/RoomView/services/joinRoom.ts +++ b/app/views/RoomView/services/joinRoom.ts @@ -1,9 +1,10 @@ import { takeInquiry, takeResume } from '../../../ee/omnichannel/lib'; import log, { events, logEvent } from '../../../lib/methods/helpers/log'; import { joinRoom as joinRoomService } from '../../../lib/services/restApi'; -import { type IJoinRoomContext, type IRoomViewState } from '../definitions'; +import { type IJoinRoomContext } from '../definitions'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; -export const joinRoom = async (room: IRoomViewState['room'], { requestJoinCode, onJoin }: IJoinRoomContext): Promise => { +export const joinRoom = async (room: TRoomOrPreview, { requestJoinCode, onJoin }: IJoinRoomContext): Promise => { logEvent(events.ROOM_JOIN); try { if (room.t === 'l') { @@ -25,7 +26,7 @@ export const joinRoom = async (room: IRoomViewState['room'], { requestJoinCode, } }; -export const resumeRoom = async (room: IRoomViewState['room'], onJoin: () => void): Promise => { +export const resumeRoom = async (room: TRoomOrPreview, onJoin: () => void): Promise => { logEvent(events.ROOM_RESUME); try { if (room.t === 'l') { diff --git a/app/views/RoomView/services/parseRoomRoute.ts b/app/views/RoomView/services/parseRoomRoute.ts index 97baacf9d8e..96e0f12ce38 100644 --- a/app/views/RoomView/services/parseRoomRoute.ts +++ b/app/views/RoomView/services/parseRoomRoute.ts @@ -1,12 +1,13 @@ import { getUidDirectMessage } from '../../../lib/methods/helpers'; -import { type IRoomScreenInput, type IRoomViewProps, type TRoomRouteParse } from '../definitions'; +import { type IRoomViewProps, type TRoomRouteParse } from '../definitions'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; 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 }; + const initialRoom: TRoomOrPreview = { 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/stores/RoomStore.ts b/app/views/RoomView/stores/RoomStore.ts index e442d040ccf..816dfa9f2c1 100644 --- a/app/views/RoomView/stores/RoomStore.ts +++ b/app/views/RoomView/stores/RoomStore.ts @@ -9,6 +9,7 @@ import { isGroupChat, getUidDirectMessage, canAutoTranslate as canAutoTranslateM import log from '../../../lib/methods/helpers/log'; import { isInviteSubscription } from '../../../lib/methods/isInviteSubscription'; import { type RoomType, type TSubscriptionModel } from '../../../definitions'; +import { roomObservedFields, type TRoomOrPreview } from '../../../definitions/TRoom'; import { type IRoomStoreInitParams, type IRoomViewState, @@ -16,13 +17,13 @@ import { type RoomStore, type TRoomInitResult } from '../definitions'; -import { roomAttrsUpdate, roomAttrsUpdateColumns } from '../constants'; +import { roomObservedColumns } from '../constants'; import getMessages from '../services/getMessages'; import { joinRoom, resumeRoom } from '../services/joinRoom'; -const OBSERVED_COLUMNS = Object.values(roomAttrsUpdateColumns); +const OBSERVED_COLUMNS = Object.values(roomObservedColumns); -const EMPTY_ROOM: IRoomViewState['room'] = { rid: '', t: '' }; +const EMPTY_ROOM: TRoomOrPreview = { rid: '', t: '' }; const EMPTY_MEMBER: IRoomViewState['member'] = {}; const INIT_MAX_ATTEMPTS = 3; @@ -33,7 +34,7 @@ interface IDirectMessageMember { member: IRoomViewState['member']; } -const getRoomMember = async (room: IRoomViewState['room']): Promise => { +const getRoomMember = async (room: TRoomOrPreview): Promise => { if ('id' in room && room.t === 'd' && !isGroupChat(room)) { const roomUserId = getUidDirectMessage(room); try { @@ -61,7 +62,7 @@ type TLoadRoomResult = const loadRoom = async ( rid: string, - room: IRoomViewState['room'], + room: TRoomOrPreview, joined: boolean, { tmid, onThreadMessagesLoaded, signal }: IRoomStoreInitParams ): Promise => { @@ -117,7 +118,7 @@ const loadRoom = async ( const createRoomState = ( rid: string | undefined, - initialRoom: IRoomViewState['room'] = EMPTY_ROOM, + initialRoom: TRoomOrPreview = EMPTY_ROOM, roomUserId: string | null | undefined = null ): StateCreator => (set, get) => ({ @@ -179,7 +180,7 @@ export function observeRoom(rid: string | undefined, store: RoomStore, onReady?: .get('subscriptions') .query(Q.where('rid', rid)) .observeWithColumns([...OBSERVED_COLUMNS, 'last_message']); - const subscription = observable.subscribe((rows: IRoomViewState['room'][]) => { + const subscription = observable.subscribe((rows: TRoomOrPreview[]) => { const next = rows[0]; const previous = store.getState(); if (!next) { @@ -187,7 +188,7 @@ export function observeRoom(rid: string | undefined, store: RoomStore, onReady?: return; } const roomChanged = - next !== previous.room || roomAttrsUpdate.some(attr => previous.roomUpdate[attr] !== (next as TSubscriptionModel)[attr]); + next !== previous.room || roomObservedFields.some(attr => previous.roomUpdate[attr] !== (next as TSubscriptionModel)[attr]); const lastMessageFromAgent = next.t === 'l' && !!(next.lastMessage && !next.lastMessage.token && next.lastMessage.u); if (!roomChanged && previous.subscribed && lastMessageFromAgent === previous.lastMessageFromAgent) { return; @@ -200,7 +201,7 @@ export function observeRoom(rid: string | undefined, store: RoomStore, onReady?: ? { room: next, roomUpdate: Object.fromEntries( - roomAttrsUpdate.map(attr => [attr, (next as TSubscriptionModel)[attr]]) + roomObservedFields.map(attr => [attr, (next as TSubscriptionModel)[attr]]) ) as IRoomViewState['roomUpdate'] } : {}) @@ -216,6 +217,6 @@ export const createRoomStore = ({ roomUserId }: { rid?: string; - initialRoom: IRoomViewState['room']; + initialRoom: TRoomOrPreview; roomUserId?: string | null; }): RoomStore => createStore(createRoomState(rid, initialRoom, roomUserId)); diff --git a/app/views/RoomView/stores/RoomStoreContext.tsx b/app/views/RoomView/stores/RoomStoreContext.tsx index ae198a36c5c..fd6d19431ff 100644 --- a/app/views/RoomView/stores/RoomStoreContext.tsx +++ b/app/views/RoomView/stores/RoomStoreContext.tsx @@ -1,7 +1,9 @@ import { createContext, useContext } from 'react'; -import { useStore, type StoreApi } from 'zustand'; +import { useStore } from 'zustand'; import { type RoomState, type RoomStore } from '../definitions'; +import { useRoomWithUpdateFromStore } from '../../../lib/hooks/useRoomWithUpdateFromStore'; +import { type TRoomOrPreview } from '../../../definitions/TRoom'; export const RoomStoreContext = createContext(null); @@ -15,13 +17,4 @@ const useRoomStoreApi = (): RoomStore => { export const useRoomStore = (selector: (state: RoomState) => T): T => useStore(useRoomStoreApi(), selector); -const useRerenderOnRoomMutatedInPlace = (store: StoreApi): void => { - useStore(store, s => s.roomUpdate); -}; - -export const useRoomWithUpdateFromStore = (store: StoreApi): S['room'] => { - useRerenderOnRoomMutatedInPlace(store); - return useStore(store, s => s.room); -}; - -export const useRoomWithUpdate = (): RoomState['room'] => useRoomWithUpdateFromStore(useRoomStoreApi()); +export const useRoomWithUpdate = (): TRoomOrPreview => useRoomWithUpdateFromStore(useRoomStoreApi()); diff --git a/app/views/RoomView/stores/__tests__/RoomStore.test.ts b/app/views/RoomView/stores/__tests__/RoomStore.test.ts index 1b7753679cf..adb25813640 100644 --- a/app/views/RoomView/stores/__tests__/RoomStore.test.ts +++ b/app/views/RoomView/stores/__tests__/RoomStore.test.ts @@ -5,7 +5,8 @@ import { getUserInfo } from '../../../../lib/services/restApi'; import { isGroupChat } from '../../../../lib/methods/helpers'; import { isInviteSubscription } from '../../../../lib/methods/isInviteSubscription'; import log from '../../../../lib/methods/helpers/log'; -import { roomAttrsUpdate, roomAttrsUpdateColumns } from '../../constants'; +import { roomObservedFields } from '../../../../definitions/TRoom'; +import { roomObservedColumns } from '../../constants'; import getMessages from '../../services/getMessages'; import { createRoomStore, observeRoom } from '../RoomStore'; @@ -448,7 +449,7 @@ describe('RoomStore', () => { expect(store.getState().joined).toBe(true); }); - it('roomAttrsUpdateColumns has exactly one entry per roomAttrsUpdate key', () => { - expect(Object.keys(roomAttrsUpdateColumns).sort()).toEqual([...roomAttrsUpdate].sort()); + it('roomObservedColumns has exactly one entry per roomObservedFields key', () => { + expect(Object.keys(roomObservedColumns).sort()).toEqual([...roomObservedFields].sort()); }); }); diff --git a/app/views/ShareView/Header.tsx b/app/views/ShareView/Header.tsx index e750a603a8d..b2f7037eddb 100644 --- a/app/views/ShareView/Header.tsx +++ b/app/views/ShareView/Header.tsx @@ -7,7 +7,8 @@ import { themes } from '../../lib/constants/colors'; import { useTheme } from '../../theme'; import sharedStyles from '../Styles'; import { makeThreadName } from '../../lib/methods/helpers/room'; -import { type ISubscription, type TThreadModel } from '../../definitions'; +import { type TThreadModel } from '../../definitions'; +import { type TRoomOrPreview } from '../../definitions/TRoom'; import { getRoomTitle, isGroupChat, isAndroid, isTablet } from '../../lib/methods/helpers'; import { getMessageById } from '../../lib/database/services/Message'; @@ -38,7 +39,7 @@ const styles = StyleSheet.create({ }); interface IHeader { - room: ISubscription; + room: TRoomOrPreview; thread: TThreadModel | string; } diff --git a/app/views/ShareView/index.tsx b/app/views/ShareView/index.tsx index 58c1929b9c5..85cb737dc64 100644 --- a/app/views/ShareView/index.tsx +++ b/app/views/ShareView/index.tsx @@ -31,9 +31,9 @@ import { type IShareAttachment, type IUser, RootEnum, - type TSubscriptionModel, type TThreadModel } from '../../definitions'; +import { type TRoomOrPreview } from '../../definitions/TRoom'; import { sendAttachments } from '../../lib/methods/sendFileMessage/sendAttachments'; import { sendMessage } from '../../lib/methods/sendMessage'; import { hasPermission, isAndroid, canUploadFile, isReadOnly, isBlocked } from '../../lib/methods/helpers'; @@ -50,7 +50,7 @@ interface IShareViewState { readOnly: boolean; attachments: IShareAttachment[]; text: string; - room: TSubscriptionModel; + room: TRoomOrPreview; thread: TThreadModel | string; maxFileSize?: number; mediaAllowList?: string; @@ -96,7 +96,7 @@ class ShareView extends Component { readOnly: false, attachments: [], text: props.route.params?.text ?? '', - room: props.route.params?.room ?? {}, + room: props.route.params?.room ?? { rid: '', t: '' }, thread: props.route.params?.thread ?? {}, maxFileSize: this.isShareExtension ? this.serverInfo?.FileUpload_MaxFileSize : props.FileUpload_MaxFileSize, mediaAllowList: this.isShareExtension ? this.serverInfo?.FileUpload_MediaTypeWhiteList : props.FileUpload_MediaTypeWhiteList From 5e772295eda217bc89384bf9be525599fd314f9b Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 17:39:11 -0300 Subject: [PATCH 5/6] test: extract media transfer ownership tests into a focused suite with per-instance probes --- .../MessageComposer/MessageComposer.test.tsx | 194 -------------- .../__tests__/mediaTransferOwnership.test.tsx | 236 ++++++++++++++++++ 2 files changed, 236 insertions(+), 194 deletions(-) create mode 100644 app/containers/MessageComposer/__tests__/mediaTransferOwnership.test.tsx diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index 168d9abe5c5..131ab775ce1 100644 --- a/app/containers/MessageComposer/MessageComposer.test.tsx +++ b/app/containers/MessageComposer/MessageComposer.test.tsx @@ -23,37 +23,6 @@ import { useMessageComposerApi } from './context'; import { type IMessageComposerRef } from './interfaces'; import { sendFileMessage } from '../../lib/methods/sendFileMessage'; import { runSlashCommand } from '../../lib/services/restApi'; -import { useChooseMedia } from './hooks/useChooseMedia'; -import { useMessageActionStoreApi } from '../message/stores/MessageActionStore'; -import { useAltTextSupported } from '../../lib/hooks/useAltTextSupported'; - -jest.mock('expo-document-picker', () => ({ - getDocumentAsync: jest.fn() -})); - -jest.mock('../../lib/methods/helpers/ImagePicker/ImagePicker', () => ({ - __esModule: true, - default: { - openCamera: jest.fn(), - openPicker: jest.fn() - } -})); - -jest.mock('../../lib/database/services/Subscription', () => ({ - getSubscriptionByRoomId: jest.fn() -})); - -jest.mock('../../lib/database/services/Thread', () => ({ - getThreadById: jest.fn() -})); - -jest.mock('../../lib/navigation/appNavigation', () => ({ - navigate: jest.fn() -})); - -jest.mock('../../lib/hooks/useAltTextSupported', () => ({ - useAltTextSupported: jest.fn() -})); jest.useFakeTimers(); @@ -175,29 +144,6 @@ const Render = ({ ); -type MediaTransferProbe = ReturnType; -let mediaTransferProbe: MediaTransferProbe; -let mediaActionStore: ReturnType; -const mediaTransferProbes: Record = {}; -const mediaActionStores: Record> = {}; - -const MediaTransferProbe = ({ - name = 'default', - rid = 'rid', - tmid = 'thread-id' -}: { - name?: string; - rid?: string; - tmid?: string; -}) => { - const probe = useChooseMedia({ rid, tmid, permissionToUpload: true }); - mediaTransferProbe = probe; - mediaTransferProbes[name] = probe; - mediaActionStore = useMessageActionStoreApi(); - mediaActionStores[name] = mediaActionStore; - return null; -}; - const AttachmentSeeder = ({ attachments }: { attachments: IShareAttachment[] }) => { const { addAttachments } = useMessageComposerApi(); @@ -267,23 +213,6 @@ beforeEach(() => { sharedValue.value = false; // reset before each test sharedValueSearchbar.value = false; keyboardHeightSharedValue.value = 0; - mediaTransferProbe = undefined as unknown as MediaTransferProbe; - mediaActionStore = undefined as unknown as ReturnType; - Object.keys(mediaTransferProbes).forEach(key => delete mediaTransferProbes[key]); - Object.keys(mediaActionStores).forEach(key => delete mediaActionStores[key]); - (useAltTextSupported as jest.Mock).mockReturnValue(false); - (require('expo-document-picker').getDocumentAsync as jest.Mock).mockReset(); - const imagePicker = require('../../lib/methods/helpers/ImagePicker/ImagePicker').default; - imagePicker.openCamera.mockReset(); - imagePicker.openPicker.mockReset(); - require('../../lib/database/services/Subscription').getSubscriptionByRoomId.mockResolvedValue({ - rid: 'rid', - t: 'c', - roles: [], - observe: () => ({ subscribe: () => ({ unsubscribe: jest.fn() }) }) - }); - require('../../lib/database/services/Thread').getThreadById.mockResolvedValue({ id: 'thread-id' }); - require('../../lib/navigation/appNavigation').navigate.mockClear(); }); describe('MessageComposer', () => { @@ -922,127 +851,4 @@ describe('MessageComposer', () => { expect(screen.getByTestId('composer-quote-abc')).toBeOnTheScreen(); }); }); - - describe('media transfer ownership', () => { - const attachment = { filename: 'legacy.pdf', size: 12, mime: 'application/pdf', path: 'file:///tmp/legacy.pdf' }; - - test('legacy transfer reads current text while retaining Quote IDs captured by the initiating render', async () => { - let resolveDocument!: (result: unknown) => void; - (require('expo-document-picker').getDocumentAsync as jest.Mock).mockReturnValueOnce( - new Promise(resolve => (resolveDocument = resolve)) - ); - const ref = { current: null } as RefObject; - render( - - - - ); - await waitFor(() => expect(mediaTransferProbe).toBeDefined()); - - const choosePromise = mediaTransferProbe.chooseFile(); - ref.current?.setInput('awaiting text'); - mediaActionStore.getState().actions.startReacting('react-now'); - resolveDocument({ - canceled: false, - assets: [{ name: attachment.filename, size: attachment.size, mimeType: attachment.mime, uri: attachment.path }] - }); - await choosePromise; - ref.current?.setInput('current text'); - mediaActionStore.getState().actions.startReacting('react-after-resolution'); - - const navigate = require('../../lib/navigation/appNavigation').navigate as jest.Mock; - const params = navigate.mock.calls[0][1]; - expect(params.startShareView()).toEqual({ text: 'current text', selectedMessages: ['old-quote'] }); - params.finishShareView('', []); - expect(ref.current?.getText()).toBe(''); - expect(mediaActionStore.getState().action).toBeNull(); - }); - - test.each(['chooseFile', 'takePhoto', 'chooseFromLibrary'] as const)( - '%s cancellation leaves input and Quotes unchanged', - async method => { - const ref = { current: null } as RefObject; - render( - - - - ); - await waitFor(() => expect(mediaTransferProbe).toBeDefined()); - ref.current?.setInput('kept text'); - if (method === 'chooseFile') { - (require('expo-document-picker').getDocumentAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); - } else { - const imagePicker = require('../../lib/methods/helpers/ImagePicker/ImagePicker').default; - imagePicker[method === 'takePhoto' ? 'openCamera' : 'openPicker'].mockRejectedValueOnce(new Error('cancelled')); - } - - await mediaTransferProbe[method](); - expect(require('../../lib/navigation/appNavigation').navigate).not.toHaveBeenCalled(); - expect(ref.current?.getText()).toBe('kept text'); - expect(mediaActionStore.getState().action).toEqual({ kind: 'quote', messageIds: ['kept-quote'] }); - } - ); - - test('same-rid Room and Thread composers transfer and restore independently', async () => { - const roomRef = { current: null } as RefObject; - const threadRef = { current: null } as RefObject; - const Dual = () => ( - - - - - - - - - - - - - - - - - ); - (require('expo-document-picker').getDocumentAsync as jest.Mock).mockResolvedValue({ - canceled: false, - assets: [{ name: 'x.pdf', size: 1, mimeType: 'application/pdf', uri: 'file:///x.pdf' }] - }); - render(); - await waitFor(() => expect(mediaTransferProbes.room).toBeDefined()); - roomRef.current?.setInput('room text'); - threadRef.current?.setInput('thread text'); - await Promise.all([mediaTransferProbes.room.chooseFile(), mediaTransferProbes.thread.chooseFile()]); - - const navigate = require('../../lib/navigation/appNavigation').navigate as jest.Mock; - expect(navigate).toHaveBeenCalledTimes(2); - const roomParams = navigate.mock.calls.find(([, params]) => params.thread === '')[1]; - const threadParams = navigate.mock.calls.find(([, params]) => params.thread?.id === 'thread-id')[1]; - expect(roomParams.startShareView().text).toBe('room text'); - expect(threadParams.startShareView().text).toBe('thread text'); - roomParams.finishShareView('room restored', ['room-restored']); - threadParams.finishShareView('thread restored', ['thread-restored']); - expect(roomRef.current?.getText()).toBe('room restored'); - expect(threadRef.current?.getText()).toBe('thread restored'); - expect(mediaActionStores.room.getState().action).toEqual({ kind: 'quote', messageIds: ['room-restored'] }); - expect(mediaActionStores.thread.getState().action).toEqual({ kind: 'quote', messageIds: ['thread-restored'] }); - }); - - test('alt-text capable workspaces keep selected media inline', async () => { - (useAltTextSupported as jest.Mock).mockReturnValue(true); - (require('expo-document-picker').getDocumentAsync as jest.Mock).mockResolvedValueOnce({ - canceled: false, - assets: [{ name: 'inline.pdf', size: 1, mimeType: 'application/pdf', uri: 'file:///inline.pdf' }] - }); - render( - - - - ); - await waitFor(() => expect(mediaTransferProbe).toBeDefined()); - await mediaTransferProbe.chooseFile(); - await waitFor(() => expect(screen.getByTestId('message-composer-attachments')).toBeOnTheScreen()); - expect(require('../../lib/navigation/appNavigation').navigate).not.toHaveBeenCalled(); - }); - }); }); diff --git a/app/containers/MessageComposer/__tests__/mediaTransferOwnership.test.tsx b/app/containers/MessageComposer/__tests__/mediaTransferOwnership.test.tsx new file mode 100644 index 00000000000..0836d55b4a6 --- /dev/null +++ b/app/containers/MessageComposer/__tests__/mediaTransferOwnership.test.tsx @@ -0,0 +1,236 @@ +import { createRef, type RefObject } from 'react'; +import { render, screen, waitFor } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import { getDocumentAsync } from 'expo-document-picker'; + +import { MessageComposerContainer } from '../MessageComposerContainer'; +import { ComposerAttachments } from '../components/Attachments/ComposerAttachments'; +import { ComposerProvider } from '../ComposerStore'; +import { MessageActionProvider, useMessageActionStoreApi } from '../../message/stores/MessageActionStore'; +import { useChooseMedia } from '../hooks/useChooseMedia'; +import { type IMessageComposerRef } from '../interfaces'; +import { setPermissions } from '../../../actions/permissions'; +import { selectServerRequest } from '../../../actions/server'; +import { setUser } from '../../../actions/login'; +import { mockedStore } from '../../../reducers/mockedStore'; +import { type TMessageActionState } from '../../../definitions'; +import { initStore } from '../../../lib/store/auxStore'; +import ImagePicker from '../../../lib/methods/helpers/ImagePicker/ImagePicker'; +import Navigation from '../../../lib/navigation/appNavigation'; +import { getSubscriptionByRoomId } from '../../../lib/database/services/Subscription'; +import { getThreadById } from '../../../lib/database/services/Thread'; +import { useAltTextSupported } from '../../../lib/hooks/useAltTextSupported'; + +jest.useFakeTimers(); + +jest.mock('expo-document-picker', () => ({ + getDocumentAsync: jest.fn() +})); + +jest.mock('../../../lib/methods/helpers/ImagePicker/ImagePicker', () => ({ + __esModule: true, + default: { + openCamera: jest.fn(), + openPicker: jest.fn() + } +})); + +jest.mock('../../../lib/database/services/Subscription', () => ({ + getSubscriptionByRoomId: jest.fn() +})); + +jest.mock('../../../lib/database/services/Thread', () => ({ + getThreadById: jest.fn() +})); + +jest.mock('../../../lib/navigation/appNavigation', () => ({ + __esModule: true, + default: { navigate: jest.fn() } +})); + +jest.mock('../../../lib/methods/draftMessage', () => ({ + loadDraftMessage: jest.fn(() => Promise.resolve('')), + saveDraftMessage: jest.fn() +})); + +jest.mock('../../../lib/hooks/useAltTextSupported', () => ({ + useAltTextSupported: jest.fn() +})); + +jest.mock('../../../lib/database/services/Message', () => ({ + getMessageById: (messageId: string) => ({ id: messageId, rid: 'rid', msg: `Message ${messageId}`, attachments: [] }) +})); + +jest.mock('../hooks/useMessage', () => ({ + useMessage: (messageId: string) => ({ id: messageId, msg: 'quote this', u: { username: 'rocket.cat' } }) +})); + +mockedStore.dispatch(selectServerRequest('https://open.rocket.chat', '6.4.0')); +mockedStore.dispatch(setUser({ id: 'abc', username: 'rocket.cat', name: 'Rocket Cat', roles: ['user'] })); +mockedStore.dispatch(setPermissions({ 'mobile-upload-file': ['user'] })); +initStore(mockedStore); + +const composerContext = { + rid: 'rid', + room: { rid: 'rid', t: 'd', name: 'Rocket Chat', fname: 'Rocket Chat', usernames: ['user1', 'user2'], federated: false }, + sharing: false, + editCancel: jest.fn(), + editRequest: jest.fn(), + onSendMessage: jest.fn(), + onRemoveQuoteMessage: jest.fn() +}; + +const document = { name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }; + +type MediaHandles = { + chooseMedia: ReturnType; + actionStore: ReturnType; +}; + +const MediaProbe = ({ handles, rid, tmid }: { handles: RefObject; rid: string; tmid: string }) => { + handles.current = { + chooseMedia: useChooseMedia({ rid, tmid, permissionToUpload: true }), + actionStore: useMessageActionStoreApi() + }; + return null; +}; + +const composerInstance = ({ + rid = 'rid', + tmid = 'thread-id', + action +}: { + rid?: string; + tmid?: string; + action?: TMessageActionState; +}) => { + const composerRef = createRef(); + const handles = createRef(); + const element = ( + + + + <> + + + + + + + ); + return { + element, + getText: () => composerRef.current?.getText(), + setInput: (text: string) => composerRef.current?.setInput(text), + media: () => handles.current!.chooseMedia, + action: () => handles.current!.actionStore.getState().action, + startReacting: (messageId: string) => handles.current!.actionStore.getState().actions.startReacting(messageId), + mounted: () => waitFor(() => expect(handles.current).not.toBeNull()) + }; +}; + +const renderComposers = async (...instances: ReturnType[]) => { + render({instances.map(instance => instance.element)}); + await Promise.all(instances.map(instance => instance.mounted())); +}; + +const shareViewParams = (predicate: (params: any) => boolean = () => true) => + jest + .mocked(Navigation.navigate) + .mock.calls.map(([, params]: any[]) => params) + .find(predicate); + +beforeEach(() => { + jest.mocked(useAltTextSupported).mockReturnValue(false); + jest.mocked(getDocumentAsync).mockReset(); + jest.mocked(ImagePicker.openCamera).mockReset(); + jest.mocked(ImagePicker.openPicker).mockReset(); + jest.mocked(Navigation.navigate).mockClear(); + jest.mocked(getSubscriptionByRoomId).mockResolvedValue({ + rid: 'rid', + t: 'c', + roles: [], + observe: () => ({ subscribe: () => ({ unsubscribe: jest.fn() }) }) + } as any); + jest.mocked(getThreadById).mockResolvedValue({ id: 'thread-id' } as any); +}); + +describe('media transfer ownership', () => { + test('legacy transfer reads current text while retaining Quote IDs captured by the initiating render', async () => { + let resolveDocument!: (result: unknown) => void; + jest.mocked(getDocumentAsync).mockReturnValueOnce(new Promise(resolve => (resolveDocument = resolve)) as any); + const composer = composerInstance({ action: { kind: 'quote', messageIds: ['old-quote'] } }); + await renderComposers(composer); + + const choosePromise = composer.media().chooseFile(); + composer.setInput('awaiting text'); + composer.startReacting('react-now'); + resolveDocument({ canceled: false, assets: [document] }); + await choosePromise; + composer.setInput('current text'); + composer.startReacting('react-after-resolution'); + + const params = shareViewParams(); + expect(params.startShareView()).toEqual({ text: 'current text', selectedMessages: ['old-quote'] }); + params.finishShareView('', []); + expect(composer.getText()).toBe(''); + expect(composer.action()).toBeNull(); + }); + + test.each(['chooseFile', 'takePhoto', 'chooseFromLibrary'] as const)( + '%s cancellation leaves input and Quotes unchanged', + async method => { + const composer = composerInstance({ action: { kind: 'quote', messageIds: ['kept-quote'] } }); + await renderComposers(composer); + composer.setInput('kept text'); + if (method === 'chooseFile') { + jest.mocked(getDocumentAsync).mockResolvedValueOnce({ canceled: true } as any); + } else { + jest + .mocked(method === 'takePhoto' ? ImagePicker.openCamera : ImagePicker.openPicker) + .mockRejectedValueOnce(new Error('cancelled')); + } + + await composer.media()[method](); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(composer.getText()).toBe('kept text'); + expect(composer.action()).toEqual({ kind: 'quote', messageIds: ['kept-quote'] }); + } + ); + + test('same-rid Room and Thread composers transfer and restore independently', async () => { + jest.mocked(getDocumentAsync).mockResolvedValue({ canceled: false, assets: [document] } as any); + const room = composerInstance({ rid: 'same-rid', tmid: '', action: { kind: 'quote', messageIds: ['room-quote'] } }); + const thread = composerInstance({ + rid: 'same-rid', + tmid: 'thread-id', + action: { kind: 'quote', messageIds: ['thread-quote'] } + }); + await renderComposers(room, thread); + room.setInput('room text'); + thread.setInput('thread text'); + await Promise.all([room.media().chooseFile(), thread.media().chooseFile()]); + + expect(Navigation.navigate).toHaveBeenCalledTimes(2); + const roomParams = shareViewParams(params => params.thread === ''); + const threadParams = shareViewParams(params => params.thread?.id === 'thread-id'); + expect(roomParams.startShareView().text).toBe('room text'); + expect(threadParams.startShareView().text).toBe('thread text'); + roomParams.finishShareView('room restored', ['room-restored']); + threadParams.finishShareView('thread restored', ['thread-restored']); + expect(room.getText()).toBe('room restored'); + expect(thread.getText()).toBe('thread restored'); + expect(room.action()).toEqual({ kind: 'quote', messageIds: ['room-restored'] }); + expect(thread.action()).toEqual({ kind: 'quote', messageIds: ['thread-restored'] }); + }); + + test('alt-text capable workspaces keep selected media inline', async () => { + jest.mocked(useAltTextSupported).mockReturnValue(true); + jest.mocked(getDocumentAsync).mockResolvedValueOnce({ canceled: false, assets: [document] } as any); + const composer = composerInstance({}); + await renderComposers(composer); + await composer.media().chooseFile(); + await waitFor(() => expect(screen.getByTestId('message-composer-attachments')).toBeOnTheScreen()); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +}); From 41aa501867f62883edd8f76159b45282831a25b1 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 8 Sep 2026 17:46:38 -0300 Subject: [PATCH 6/6] test: restore real timers in afterEach for ShareView --- app/views/ShareView/ShareView.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/views/ShareView/ShareView.test.tsx b/app/views/ShareView/ShareView.test.tsx index ac07dae2a86..fcdc7ce9838 100644 --- a/app/views/ShareView/ShareView.test.tsx +++ b/app/views/ShareView/ShareView.test.tsx @@ -155,6 +155,10 @@ const makeInstance = ({ }; describe('ShareView', () => { + afterEach(() => { + jest.useRealTimers(); + }); + beforeEach(() => { jest.clearAllMocks(); mockGetSubscriptionByRoomId.mockResolvedValue({ @@ -342,7 +346,6 @@ describe('ShareView', () => { expect(setInput).toHaveBeenCalledWith('shared text'); expect(shareView.getSelectedMessageIds()).toEqual(['quote-1']); - jest.useRealTimers(); }); it('keeps selected attachment fallback and reload behavior when attachments are removed', () => { @@ -470,7 +473,6 @@ describe('ShareView', () => { expect(originComposerRef.current?.getText()).toBe('Share text'); expect(originStore.getState().action).toEqual({ kind: 'quote', messageIds: ['share-quote'] }); act(() => shareRender.unmount()); - jest.useRealTimers(); }); it.each(['success', 'failure'] as const)('bridges real callbacks through ShareView send %s', async outcome => { @@ -549,7 +551,6 @@ describe('ShareView', () => { const expectedOriginAction = outcome === 'failure' ? { kind: 'quote', messageIds: ['share-quote'] } : null; expect(originStore.getState().action).toEqual(expectedOriginAction); uploadSpy.mockRestore(); - jest.useRealTimers(); }); it('saves and restores selected attachment text through the rendered composer', () => {