Skip to content
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,10 +15,8 @@ import {
useEditRequest,
useOnRemoveQuoteMessage,
useOnSendMessage,
useSetQuotesAndText,
useGetText,
useUpdateAutocompleteVisible
} from '../ComposerStore';
} from './ComposerStore';

const room = { rid: 'rid-1', t: 'c' };

Expand All @@ -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 = () => ({
Expand All @@ -47,8 +43,6 @@ const useAllComposerHooks = () => ({
editRequest: useEditRequest(),
onRemoveQuoteMessage: useOnRemoveQuoteMessage(),
onSendMessage: useOnSendMessage(),
setQuotesAndText: useSetQuotesAndText(),
getText: useGetText(),
updateAutocompleteVisible: useUpdateAutocompleteVisible()
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
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 } from '../../definitions';
import { type IRoomWithUpdateState, useRoomWithUpdateFromStore } from '../../lib/hooks/useRoomWithUpdateFromStore';
import { type TRoomOrPreview } from '../../definitions/TRoom';

export type ComposerState = IRoomWithUpdateState & {
room: TRoomOrPreview;
rid?: string;
t?: string;
tmid?: string;
sharing?: boolean;
isAutocompleteVisible: boolean;
editCancel?: () => void;
editRequest?: (message: Pick<IMessage, 'id' | 'msg' | 'rid'> & { attachments?: IMessageEditAttachment[] }) => Promise<void>;
onRemoveQuoteMessage?: (messageId: string) => void;
onSendMessage?: (message?: string, tshow?: boolean) => void;
updateAutocompleteVisible: (updatedAutocompleteVisible: boolean) => void;
};

export type TComposerExternalState = Omit<ComposerState, 'isAutocompleteVisible' | 'updateAutocompleteVisible'>;
export type ComposerStore = StoreApi<ComposerState>;

export const createComposerStore = (initial: TComposerExternalState) =>
createStore<ComposerState>()(set => ({
Expand Down Expand Up @@ -50,7 +69,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);
53 changes: 48 additions & 5 deletions app/containers/MessageComposer/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,14 +12,15 @@ 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';

Expand Down Expand Up @@ -121,16 +122,18 @@ const initialContext = {
const Render = ({
context,
action,
children
children,
forwardedRef
}: {
context?: Partial<ComposerState>;
action?: TMessageActionState;
children?: ReactElement;
forwardedRef?: RefObject<IMessageComposerRef | null>;
}) => (
<Provider store={mockedStore}>
<MessageActionProvider initialAction={action}>
<ComposerProvider {...initialContext} {...context}>
<MessageComposerContainer>
<MessageComposerContainer ref={forwardedRef}>
<>
<ComposerAttachments />
{children}
Expand Down Expand Up @@ -807,5 +810,45 @@ 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<IMessageComposerRef | null>;
(sendFileMessage as jest.Mock).mockImplementationOnce(() => new Promise<void>(resolve => (resolveUpload = resolve)));
render(
<Render forwardedRef={composerRef} action={{ kind: 'quote', messageIds: ['abc'] }}>
<AttachmentSeeder attachments={[attachment]} />
</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<IMessageComposerRef | null>;
render(
<Render forwardedRef={composerRef} action={{ kind: 'quote', messageIds: ['abc'] }}>
<AttachmentSeeder attachments={[attachment]} />
</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();
});
});
});
14 changes: 6 additions & 8 deletions app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 : [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -250,6 +246,8 @@ export const MessageComposer = ({
value={{
sendMessage: handleSendMessage,
onEmojiSelected,
getText: () => composerInputComponentRef.current?.getText(),
setInput: text => composerInputComponentRef.current?.setInput(text),
closeEmojiKeyboardAndAction,
focus: focusComposerInput
}}>
Expand Down
Loading
Loading