diff --git a/.maestro/tests/room/threads.yaml b/.maestro/tests/room/threads.yaml index 20261bdc51c..f45f17151b4 100644 --- a/.maestro/tests/room/threads.yaml +++ b/.maestro/tests/room/threads.yaml @@ -294,6 +294,14 @@ tags: timeout: 60000 - tapOn: id: 'header-back' +- extendedWaitUntil: + notVisible: + id: 'room-view-title-thread' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'room-view-title-${output.room.name}' + timeout: 60000 # should create thread delete the message and the thread show the correct number of messages - runFlow: diff --git a/CONTEXT.md b/CONTEXT.md index 8c2c72e9fc4..21fcfccf30c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,18 +2,22 @@ ## Rooms & Conversations -| Term | Definition | Aliases to avoid | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| **Room** | A server-side conversation container with shared state (name, type, settings) | Chat, conversation | -| **Subscription** | A user's personal relationship to a Room, holding per-user state (unread count, favorite, muted, open) — never a **DDP Subscription** | Membership, room entry | -| **Channel** | A public Room (type `'c'`) visible to all Workspace users | Public room | -| **Group** | A private Room (type `'p'`) visible only to invited members | Private room, private channel | -| **Direct Message** | A 1-on-1 private Room (type `'d'`) between two users | DM, PM, private message | -| **Thread** | A branched conversation spawned from a single Message, identified by `tmid` (thread message id) | Reply chain | -| **Discussion** | A separate Room spawned from a parent Room, identified by `prid` (parent room id) — unlike Threads, Discussions are full Rooms | Sub-room, sub-channel | -| **Team** | An organizational container that groups multiple Channels and users under a single entity | Workspace (a different thing) | -| **Broadcast Room** | A Room where only authorized users can send Messages; other users can only Reply Broadcast to existing Messages | Broadcast channel | -| **Reply Broadcast** | The action of replying to a Message in a Broadcast Room when the current user cannot send regular Messages | Broadcast reply | +| Term | Definition | Aliases to avoid | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| **Room** | A server-side conversation container with shared state (name, type, settings) | Chat, conversation | +| **Subscription** | A user's personal relationship to a Room, holding per-user state (unread count, favorite, muted, open), never a **DDP Subscription** | Membership, room entry | +| **Subscribed Room** | A Room the user has joined, backed by a persisted Subscription model. Detected by a Subscription record whose status is not `invited`. | Joined room | +| **Preview Mode** | Viewing a Room without joining: no Subscription record exists and the Room data comes from navigation params or a REST lookup. Persists until the user joins. Detected by the absence of a Subscription. | — | +| **Invited** | A Room where a Subscription exists but its status is `invited` and not yet accepted. Detected by a Subscription whose status is `invited`. | — | +| **Room Membership** | The user's standing in a Room: exactly one of **Preview Mode**, **Invited**, or **Subscribed Room**. Derived from whether a Subscription exists and, if so, its status. | Joined flag, membership | +| **Channel** | A public Room (type `'c'`) visible to all Workspace users | Public room | +| **Group** | A private Room (type `'p'`) visible only to invited members | Private room, private channel | +| **Direct Message** | A 1-on-1 private Room (type `'d'`) between two users | DM, PM, private message | +| **Thread** | A branched conversation spawned from a single Message, identified by `tmid` (thread message id) | Reply chain | +| **Discussion** | A separate Room spawned from a parent Room, identified by `prid` (parent room id) — unlike Threads, Discussions are full Rooms | Sub-room, sub-channel | +| **Team** | An organizational container that groups multiple Channels and users under a single entity | Workspace (a different thing) | +| **Broadcast Room** | A Room where only authorized users can send Messages; other users can only Reply Broadcast to existing Messages | Broadcast channel | +| **Reply Broadcast** | The action of replying to a Message in a Broadcast Room when the current user cannot send regular Messages | Broadcast reply | ## Messages @@ -126,10 +130,12 @@ A **Last Open** and a **Last Seen** are not interchangeable — conflating them Two distinct kinds of transient per-Room state drive how the Room view renders Messages. Keep them apart. -| Term | Definition | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| **Message Action State** | The active Message Action in the Room (Quote, Edit, or React) and its target Message(s); null when none is active | -| **Positional State** | Which Message is highlighted and the jump or scroll position | +| Term | Definition | Owner | Scope | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------ | +| **Message Action State** | The active Message Action in the Room (Quote, Edit, or React) and its target Message(s); null when none is active | A per-Room MessageActionStore | One Room's Message rows and composer | +| **Positional State** | Umbrella for which Message is highlighted and the jump or scroll position. Splits across two owners (below). | — | — | +| **Jump orchestration** | Deciding to jump to a Message, resolving its anchor, and requesting the jump | RoomView (`useJumpToMessage`) | The Room screen | +| **Scroll and highlight execution** | Performing the scroll onto the target Message and rendering its highlight | The List component | The rendered Message list | ### Message Actions @@ -176,17 +182,19 @@ A **Reaction** and the frequently used emojis table store an emoji by _name_, ne ## Omnichannel / Livechat -| Term | Definition | Aliases to avoid | -| ---------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------- | -| **Omnichannel Room** | A customer-service Room (type `'l'`) connecting a Visitor to an Agent | Livechat room, support chat | -| **Visitor** | An external customer who initiates an Omnichannel conversation, identified by a unique token | Client, customer, end-user | -| **Agent** | A User designated to handle Omnichannel conversations, with `statusLivechat` (available/unavailable) | Support agent, operator, rep | -| **Inquiry** | A queued Omnichannel request waiting to be picked up or routed to an Agent | Queue item, ticket | -| **Department** | An organizational unit that groups Agents for Omnichannel routing | Team (ambiguous), group | -| **Omnichannel Source** | How an Omnichannel conversation was initiated (widget, email, sms, app, api) | Channel origin | -| **Served By** | The Agent currently assigned to handle an Omnichannel Room | Assigned agent, handler | -| **On Hold** | An Omnichannel Room temporarily paused by the Agent | Paused, suspended | -| **Transfer** | Moving an Omnichannel Room to a different Agent or Department | Forward, reassign, handoff | +| Term | Definition | Aliases to avoid | +| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| **Omnichannel Room** | A customer-service Room (type `'l'`) connecting a Visitor to an Agent | Livechat room, support chat | +| **Visitor** | An external customer who initiates an Omnichannel conversation, identified by a unique token | Client, customer, end-user | +| **Agent** | A User designated to handle Omnichannel conversations, with `statusLivechat` (available/unavailable) | Support agent, operator, rep | +| **Inquiry** | A queued Omnichannel request waiting to be picked up or routed to an Agent | Queue item, ticket | +| **Department** | An organizational unit that groups Agents for Omnichannel routing | Team (ambiguous), group | +| **Omnichannel Source** | How an Omnichannel conversation was initiated (widget, email, sms, app, api) | Channel origin | +| **Served By** | The Agent currently assigned to handle an Omnichannel Room | Assigned agent, handler | +| **On Hold** | An Omnichannel Room temporarily paused by the Agent | Paused, suspended | +| **Transfer** | Moving an Omnichannel Room to a different Agent or Department | Forward, reassign, handoff | +| **Routing Config** | Per-server Omnichannel routing settings that say whether Agents may Return to Queue and see the Inquiry queue | Livechat config, routing settings | +| **Return to Queue** | An Agent handing an Omnichannel Room back so it becomes an Inquiry again | Return inquiry, release chat | ## Encryption diff --git a/app/actions/actionsTypes.ts b/app/actions/actionsTypes.ts index 06445d9600e..1eae46f6e78 100644 --- a/app/actions/actionsTypes.ts +++ b/app/actions/actionsTypes.ts @@ -34,6 +34,7 @@ export const INQUIRY = createRequestTypes('INQUIRY', [ 'QUEUE_UPDATE', 'QUEUE_REMOVE' ]); +export const ROUTING_CONFIG = createRequestTypes('ROUTING_CONFIG'); export const APP = createRequestTypes('APP', [ 'START', 'READY', diff --git a/app/containers/MessageActions/index.tsx b/app/containers/MessageActions/index.tsx index 0019cd26c47..50c303242ba 100644 --- a/app/containers/MessageActions/index.tsx +++ b/app/containers/MessageActions/index.tsx @@ -45,7 +45,7 @@ const REFOCUS_BUFFER = 50; const isVideoConf = (message: TAnyMessageModel) => message.t === 'videoconf'; export interface IMessageActionsProps { - room: TSubscriptionModel; + getRoom: () => TSubscriptionModel; tmid?: string; user: Pick; editInit: (messageId: string) => void; @@ -81,7 +81,7 @@ const MessageActions = memo( forwardRef( ( { - room, + getRoom, tmid, user, editInit, @@ -133,7 +133,7 @@ const MessageActions = memo( createDirectMessagePermission, createDiscussionOtherUserPermission ]; - const result = await hasPermission(permission, room.rid); + const result = await hasPermission(permission, getRoom().rid); permissions = { hasEditPermission: result[0], hasDeletePermission: result[1], @@ -219,7 +219,7 @@ const MessageActions = memo( const handleCreateDiscussion = (message: TAnyMessageModel) => { logEvent(events.ROOM_MSG_ACTION_DISCUSSION); - const params = { message, channel: room, showCloseModal: true }; + const params = { message, channel: getRoom(), showCloseModal: true }; if (isMasterDetail) { Navigation.navigate('ModalStackNavigator', { screen: 'CreateDiscussionView', params }); } else { @@ -239,7 +239,7 @@ const MessageActions = memo( const handleUnread = async (message: TAnyMessageModel) => { logEvent(events.ROOM_MSG_ACTION_UNREAD); const { id: messageId, ts } = message; - const { rid } = room; + const { rid } = getRoom(); try { const db = database.active; const result = await markAsUnread({ messageId }); @@ -355,6 +355,7 @@ const MessageActions = memo( }; const handleToggleTranslation = async (message: TAnyMessageModel) => { + const room = getRoom(); try { if (!room.autoTranslateLanguage) { return; @@ -403,6 +404,7 @@ const MessageActions = memo( }; const getConversationOptions = (message: TAnyMessageModel) => { + const room = getRoom(); const options: TActionSheetOptionsItem[] = []; const videoConfBlock = isVideoConf(message); @@ -474,6 +476,7 @@ const MessageActions = memo( }; const getSharingOptions = (message: TAnyMessageModel) => { + const room = getRoom(); const options: TActionSheetOptionsItem[] = []; const videoConfBlock = isVideoConf(message); @@ -567,7 +570,7 @@ const MessageActions = memo( } // Toggle Auto-translate - if (room.autoTranslate && isFromAnotherUser) { + if (getRoom().autoTranslate && isFromAnotherUser) { options.push({ title: I18n.t(message.autoTranslate !== false ? 'View_Original' : 'Translate'), icon: 'language', @@ -624,7 +627,7 @@ const MessageActions = memo( headerHeight: HEADER_HEIGHT, customHeader: ( <> - {!isReadOnly || room.reactWhenReadOnly ? ( + {!isReadOnly || getRoom().reactWhenReadOnly ? (
) : null} diff --git a/app/containers/MessageComposer/ComposerStore.test.tsx b/app/containers/MessageComposer/ComposerStore.test.tsx new file mode 100644 index 00000000000..017ac8bdbdb --- /dev/null +++ b/app/containers/MessageComposer/ComposerStore.test.tsx @@ -0,0 +1,138 @@ +import { useContext, type ReactNode } from 'react'; +import { act, render, renderHook } from '@testing-library/react-native'; + +import { + ComposerProvider, + ComposerStoreContext, + useComposerRid, + useComposerType, + useComposerTmid, + useComposerRoomTitle, + useComposerSharing, + useIsAutocompleteVisible, + useEditCancel, + useEditRequest, + useOnRemoveQuoteMessage, + useOnSendMessage, + useUpdateAutocompleteVisible +} from './ComposerStore'; + +const fullProps = () => ({ + rid: 'rid-1', + t: 'c', + tmid: 'tmid-1', + roomTitle: 'Room 1', + sharing: false, + editCancel: jest.fn(), + editRequest: jest.fn(() => Promise.resolve()), + onRemoveQuoteMessage: jest.fn(), + onSendMessage: jest.fn() +}); + +const useAllComposerHooks = () => ({ + rid: useComposerRid(), + t: useComposerType(), + tmid: useComposerTmid(), + roomTitle: useComposerRoomTitle(), + sharing: useComposerSharing(), + isAutocompleteVisible: useIsAutocompleteVisible(), + editCancel: useEditCancel(), + editRequest: useEditRequest(), + onRemoveQuoteMessage: useOnRemoveQuoteMessage(), + onSendMessage: useOnSendMessage(), + updateAutocompleteVisible: useUpdateAutocompleteVisible() +}); + +describe('ComposerStore', () => { + it('exposes every seeded prop through its matching hook', () => { + const props = fullProps(); + const wrapper = ({ children }: { children: ReactNode }) => {children}; + + const { result } = renderHook(() => useAllComposerHooks(), { wrapper }); + + // isAutocompleteVisible/updateAutocompleteVisible are store-owned, not seeded props. + expect(result.current).toEqual({ ...props, isAutocompleteVisible: false, updateAutocompleteVisible: expect.any(Function) }); + }); + + it('toggles isAutocompleteVisible when updateAutocompleteVisible is called', () => { + const props = fullProps(); + const wrapper = ({ children }: { children: ReactNode }) => {children}; + + const { result } = renderHook( + () => ({ isAutocompleteVisible: useIsAutocompleteVisible(), update: useUpdateAutocompleteVisible() }), + { wrapper } + ); + + expect(result.current.isAutocompleteVisible).toBe(false); + + act(() => result.current.update(true)); + expect(result.current.isAutocompleteVisible).toBe(true); + + act(() => result.current.update(false)); + expect(result.current.isAutocompleteVisible).toBe(false); + }); + + it('re-renders useComposerRoomTitle when roomTitle changes', () => { + const spy = jest.fn(); + + const Probe = () => { + const roomTitle = useComposerRoomTitle(); + spy(roomTitle); + return null; + }; + const Parent = ({ roomTitle }: { roomTitle: string }) => { + const props = fullProps(); + return ( + + + + ); + }; + + const { rerender } = render(); + expect(spy).toHaveBeenLastCalledWith('old'); + + rerender(); + expect(spy).toHaveBeenLastCalledWith('new'); + }); + + it('throws when a composer hook is used outside a ComposerProvider', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => renderHook(() => useComposerRid())).toThrow('Composer store hooks must be used within a ComposerProvider'); + + consoleError.mockRestore(); + }); + + it('keeps the same store instance across re-renders (does not recreate on prop change)', () => { + const props = fullProps(); + const spy = jest.fn(); + + const Probe = () => { + spy(useContext(ComposerStoreContext)); + return null; + }; + const Parent = ({ sharing }: { sharing: boolean }) => ( + + + + ); + + const { rerender } = render(); + const first = spy.mock.calls[0][0]; + + rerender(); + const last = spy.mock.calls[spy.mock.calls.length - 1][0]; + + expect(last).toBe(first); + }); +}); diff --git a/app/containers/MessageComposer/ComposerStore.tsx b/app/containers/MessageComposer/ComposerStore.tsx new file mode 100644 index 00000000000..4c2a5578c42 --- /dev/null +++ b/app/containers/MessageComposer/ComposerStore.tsx @@ -0,0 +1,71 @@ +import { createContext, useContext, useEffect, useState, type ReactElement, type ReactNode } from 'react'; +import { createStore, useStore } from 'zustand'; +import { type StoreApi } from 'zustand'; + +import { type IMessage, type IMessageEditAttachment } from '~/definitions'; + +export type ComposerState = { + roomTitle: string; + rid?: string; + t?: string; + tmid?: string; + 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 => ({ + ...initial, + isAutocompleteVisible: false, + updateAutocompleteVisible: (isAutocompleteVisible: boolean) => set({ isAutocompleteVisible }) + })); + +export const ComposerStoreContext = createContext(null); + +export const useComposerStoreApi = (): ComposerStore => { + const store = useContext(ComposerStoreContext); + if (!store) { + throw new Error('Composer store hooks must be used within a ComposerProvider'); + } + return store; +}; + +const useComposerStore = (selector: (state: ComposerState) => T): T => useStore(useComposerStoreApi(), selector); + +export const ComposerProvider = ({ children, ...state }: { children: ReactNode } & TComposerExternalState): ReactElement => { + const [store] = useState(() => createComposerStore(state)); + + useEffect(() => { + const current = store.getState(); + const changedFields = Object.fromEntries( + Object.entries(state).filter(([field, value]) => current[field as keyof ComposerState] !== value) + ) as Partial; + if (Object.keys(changedFields).length) { + store.setState(changedFields); + } + }, [store, state]); + + return {children}; +}; + +export const useComposerRid = (): ComposerState['rid'] => useComposerStore(s => s.rid); +export const useComposerType = (): ComposerState['t'] => useComposerStore(s => s.t); +export const useComposerTmid = (): ComposerState['tmid'] => useComposerStore(s => s.tmid); +export const useComposerRoomTitle = (): ComposerState['roomTitle'] => useComposerStore(s => s.roomTitle); +export const useComposerSharing = (): ComposerState['sharing'] => useComposerStore(s => s.sharing); +export const useIsAutocompleteVisible = (): ComposerState['isAutocompleteVisible'] => + useComposerStore(s => s.isAutocompleteVisible); +export const useEditCancel = (): ComposerState['editCancel'] => useComposerStore(s => s.editCancel); +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 useUpdateAutocompleteVisible = (): ComposerState['updateAutocompleteVisible'] => + useComposerStore(s => s.updateAutocompleteVisible); diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index 401b8ced269..e470df6bc2c 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,13 +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 IRoomContext, RoomContext } from '~/views/RoomView/context'; +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'; @@ -99,17 +101,9 @@ jest.mock('~/lib/database/services/Message', () => ({ const initialContext = { rid: 'rid', + t: 'd', tmid: undefined, - room: { - rid: 'rid', - t: 'd', - tmid: undefined, - name: 'Rocket Chat', - fname: 'Rocket Chat', - usernames: ['user1', 'user2'], - prid: undefined, - federated: false - }, + roomTitle: 'Rocket Chat', sharing: false, editCancel: jest.fn(), editRequest: jest.fn(), @@ -120,22 +114,24 @@ const initialContext = { const Render = ({ context, action, - children + children, + forwardedRef }: { - context?: Partial; + context?: Partial; action?: TMessageActionState; children?: ReactElement; + forwardedRef?: RefObject; }) => ( - - + + <> {children} - + ); @@ -622,7 +618,7 @@ describe('MessageComposer', () => { test('select ! canned response inserts text and sends, autocomplete hides', async () => { const onSendMessage = jest.fn(); - render(); + render(); await fireEvent(screen.getByTestId('message-composer-input'), 'focus'); await fireEvent.changeText(screen.getByTestId('message-composer-input'), '!'); @@ -806,5 +802,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; + (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(); + }); }); }); diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index f846b63203b..c94d46f4c7f 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -4,8 +4,8 @@ import { useBackHandler } from '@react-native-community/hooks'; import { Q } from '@nozbe/watermelondb'; import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; -import { useRoomContext } from '~/views/RoomView/context'; -import { useMessageAction } from '../message/stores/MessageActionStore'; +import { useComposerRid, useComposerSharing, useComposerStoreApi, useComposerTmid } from './ComposerStore'; +import { useMessageActionKind, useMessageActionStoreApi } from '../message/stores/MessageActionStore'; import { Autocomplete } from './components'; import { MIN_HEIGHT } from './constants'; import { @@ -53,8 +53,12 @@ export const MessageComposer = ({ }); const contentHeight = useSharedValue(MIN_HEIGHT); useCloseKeyboardWhenOrientationChanges(); - const { rid, tmid, sharing, editRequest, onSendMessage, setQuotesAndText } = useRoomContext(); - const action = useMessageAction(); + const rid = useComposerRid(); + const tmid = useComposerTmid(); + const sharing = useComposerSharing(); + const composerStore = useComposerStoreApi(); + const messageActionStore = useMessageActionStoreApi(); + const actionKind = useMessageActionKind(); const alsoSendThreadToChannel = useAlsoSendThreadToChannel(); const { showEmojiKeyboard, showEmojiSearchbar, openEmojiSearchbar, resetKeyboard, keyboardHeight } = useEmojiKeyboard(); const { setAlsoSendThreadToChannel, setAutocompleteParams, clearAttachments } = useMessageComposerApi(); @@ -74,9 +78,9 @@ export const MessageComposer = ({ return false; }); - const closeEmojiKeyboardAndAction = (action?: Function, params?: any) => { + const closeEmojiKeyboardAndAction = (onClosed?: Function, params?: any) => { resetKeyboard(); - action && action(params); + onClosed?.(params); }; useImperativeHandle(forwardedRef, () => ({ @@ -94,6 +98,11 @@ export const MessageComposer = ({ const handleSendMessage = async () => { if (!rid) return; + const { editRequest, onSendMessage } = composerStore.getState(); + const { action } = messageActionStore.getState(); + const editingMessageId = action?.kind === 'edit' ? action.messageId : undefined; + const quotedMessageIds = action?.kind === 'quote' ? action.messageIds : []; + if (alsoSendThreadToChannel) { setAlsoSendThreadToChannel(false); } @@ -108,13 +117,13 @@ export const MessageComposer = ({ const textFromInput = composerInputComponentRef.current.getTextAndClear(); - if (action?.kind === 'edit') { + if (editingMessageId) { const updatedAttachments = attachments.length ? attachments.map(({ description, altText, fileId, filename }) => altTextSupported ? { description: altText || '', fileId, filename } : { description: description || '' } ) : undefined; - editRequest?.({ id: action.messageId, msg: textFromInput, rid, attachments: updatedAttachments }); + editRequest?.({ id: editingMessageId, msg: textFromInput, rid, attachments: updatedAttachments }); clearAttachments(); return; } @@ -122,8 +131,8 @@ export const MessageComposer = ({ if (attachments.length) { let quotedMessage: string | undefined; - if (action?.kind === 'quote') { - quotedMessage = await prepareQuoteMessage(textFromInput, action.messageIds, tmid); + if (quotedMessageIds.length) { + quotedMessage = await prepareQuoteMessage(textFromInput, quotedMessageIds, tmid); } try { @@ -137,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); @@ -146,8 +156,8 @@ export const MessageComposer = ({ } } - if (action?.kind === 'quote') { - const quoteMessage = await prepareQuoteMessage(textFromInput, action.messageIds, tmid); + if (quotedMessageIds.length) { + const quoteMessage = await prepareQuoteMessage(textFromInput, quotedMessageIds, tmid); onSendMessage?.(quoteMessage); return; } @@ -236,12 +246,14 @@ export const MessageComposer = ({ value={{ sendMessage: handleSendMessage, onEmojiSelected, + getText: () => composerInputComponentRef.current?.getText(), + setInput: text => composerInputComponentRef.current?.setInput(text), closeEmojiKeyboardAndAction, focus: focusComposerInput }}> ; export const MessageComposerContainer = forwardRef( - ({ children }, ref): ReactElement => { + ({ children = defaultChildren }, ref): ReactElement => { return ( diff --git a/app/containers/MessageComposer/__snapshots__/MessageComposer.test.tsx.snap b/app/containers/MessageComposer/__snapshots__/MessageComposer.test.tsx.snap index 80bd5075c9e..544ac12a650 100644 --- a/app/containers/MessageComposer/__snapshots__/MessageComposer.test.tsx.snap +++ b/app/containers/MessageComposer/__snapshots__/MessageComposer.test.tsx.snap @@ -9599,7 +9599,7 @@ exports[`MessageComposer Toolbar tap emoji 1`] = ` borderless={true} collapsable={false} delayLongPress={600} - handlerTag={11} + handlerTag={-1} handlerType="NativeViewGestureHandler" hitSlop={ { diff --git a/app/containers/MessageComposer/__tests__/MessageComposerContainer.test.tsx b/app/containers/MessageComposer/__tests__/MessageComposerContainer.test.tsx new file mode 100644 index 00000000000..3ab6e5047ae --- /dev/null +++ b/app/containers/MessageComposer/__tests__/MessageComposerContainer.test.tsx @@ -0,0 +1,237 @@ +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 '~/containers/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', + roomTitle: 'Rocket Chat', + 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(); + }); +}); diff --git a/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx b/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx index 935240f30e9..bec820265f8 100644 --- a/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx +++ b/app/containers/MessageComposer/components/Autocomplete/Autocomplete.tsx @@ -8,7 +8,8 @@ import { AutocompleteItem } from './AutocompleteItem'; import { useAutocomplete } from '~/containers/MessageComposer/hooks'; import { type IAutocompleteItemProps } from '~/containers/MessageComposer/interfaces'; import { AutocompletePreview } from './AutocompletePreview'; -import { useRoomContext } from '~/views/RoomView/context'; +import { useComposerRid, useUpdateAutocompleteVisible } from '~/containers/MessageComposer/ComposerStore'; +import { useAutocompleteA11yAnnounce } from './useAutocompleteA11yAnnounce'; import { useStyle } from './styles'; export const Autocomplete = ({ @@ -20,7 +21,10 @@ export const Autocomplete = ({ style: AnimatedStyle; accessibilityFocusOnInput: () => void; }): ReactElement | null => { - const { rid, updateAutocompleteVisible } = useRoomContext(); + useAutocompleteA11yAnnounce(); + + const rid = useComposerRid(); + const updateAutocompleteVisible = useUpdateAutocompleteVisible(); const { text, type, params } = useAutocompleteParams(); const items = useAutocomplete({ rid, diff --git a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx new file mode 100644 index 00000000000..a93493e32b5 --- /dev/null +++ b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.test.tsx @@ -0,0 +1,74 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { AccessibilityInfo } from 'react-native'; +import { type ReactNode } from 'react'; + +import { useAutocompleteA11yAnnounce } from './useAutocompleteA11yAnnounce'; +import { createComposerStore, ComposerStoreContext } from '~/containers/MessageComposer/ComposerStore'; +import { type ComposerStore } from '~/containers/MessageComposer/ComposerStore'; + +const externalState = { + rid: 'rid-1', + t: 'c', + tmid: undefined, + roomTitle: 'Room 1', + room: { rid: 'rid-1', t: 'c' }, + sharing: false, + editCancel: jest.fn(), + editRequest: jest.fn(() => Promise.resolve()), + onRemoveQuoteMessage: jest.fn(), + onSendMessage: jest.fn() +}; + +const setup = () => { + const store: ComposerStore = createComposerStore(externalState); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = renderHook(() => useAutocompleteA11yAnnounce(), { wrapper }); + return { store, ...view }; +}; + +describe('useAutocompleteA11yAnnounce', () => { + let announceSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + announceSpy = jest.spyOn(AccessibilityInfo, 'announceForAccessibility').mockImplementation(() => {}); + }); + + afterEach(() => { + announceSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('announces 800ms after a false→true transition', () => { + const { store } = setup(); + + act(() => store.getState().updateAutocompleteVisible(true)); + expect(announceSpy).not.toHaveBeenCalled(); + + act(() => jest.advanceTimersByTime(800)); + expect(announceSpy).toHaveBeenCalledTimes(1); + }); + + it('clears the pending timeout when visibility flips back before 800ms', () => { + const { store } = setup(); + + act(() => store.getState().updateAutocompleteVisible(true)); + act(() => jest.advanceTimersByTime(400)); + act(() => store.getState().updateAutocompleteVisible(false)); + + act(() => jest.advanceTimersByTime(800)); + expect(announceSpy).not.toHaveBeenCalled(); + }); + + it('clears the pending timeout on unmount', () => { + const { store, unmount } = setup(); + + act(() => store.getState().updateAutocompleteVisible(true)); + unmount(); + + act(() => jest.advanceTimersByTime(800)); + expect(announceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts new file mode 100644 index 00000000000..a361a666bab --- /dev/null +++ b/app/containers/MessageComposer/components/Autocomplete/useAutocompleteA11yAnnounce.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +import I18n from '~/i18n'; +import { useIsAutocompleteVisible } from '~/containers/MessageComposer/ComposerStore'; + +const DELAY_TO_AVOID_KEYBOARD_ANNOUNCEMENT_CONFLICT = 800; + +export const useAutocompleteA11yAnnounce = (): void => { + const isAutocompleteVisible = useIsAutocompleteVisible(); + + useEffect(() => { + if (!isAutocompleteVisible) { + return; + } + + const timeout = setTimeout(() => { + AccessibilityInfo.announceForAccessibility(I18n.t('The_autocomplete_options_are_available_above_the_input_composer')); + }, DELAY_TO_AVOID_KEYBOARD_ANNOUNCEMENT_CONFLICT); + + return () => clearTimeout(timeout); + }, [isAutocompleteVisible]); +}; diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx index abe209e3b11..5647826eab0 100644 --- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx @@ -9,10 +9,12 @@ import Navigation from '~/lib/navigation/appNavigation'; import { useMasterDetail } from '~/lib/hooks/useMasterDetail'; import { usePermissions } from '~/lib/hooks/usePermissions'; import { useCanUploadFile, useChooseMedia } from '~/containers/MessageComposer/hooks'; -import { useRoomContext } from '~/views/RoomView/context'; +import { useComposerRid, useComposerTmid, useComposerType } from '~/containers/MessageComposer/ComposerStore'; export const ActionsButton = () => { - const { rid, tmid, t } = useRoomContext(); + const rid = useComposerRid(); + const tmid = useComposerTmid(); + const t = useComposerType(); const { closeEmojiKeyboardAndAction } = useContext(MessageInnerContext); const permissionToUpload = useCanUploadFile(rid); const [permissionToViewCannedResponses] = usePermissions(['view-canned-responses'], rid); diff --git a/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx b/app/containers/MessageComposer/components/Buttons/MicOrSendButton.tsx index f7f10ea403e..cfc4a5051cd 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 { useRoomContext } from '~/views/RoomView/context'; +import { useComposerRid, useComposerSharing } from '~/containers/MessageComposer/ComposerStore'; import { MessageInnerContext, useComposerAttachments, @@ -18,7 +18,8 @@ import { useCanUploadFile } from '~/containers/MessageComposer/hooks'; import { BaseButton } from './BaseButton'; export const MicOrSendButton = (): ReactElement | null => { - const { rid, sharing } = useRoomContext(); + const rid = useComposerRid(); + const sharing = useComposerSharing(); const micOrSend = useMicOrSend(); const attachments = useComposerAttachments(); const { sendMessage } = useContext(MessageInnerContext); diff --git a/app/containers/MessageComposer/components/CancelEdit.tsx b/app/containers/MessageComposer/components/CancelEdit.tsx index 3f0adb6d608..d9350ef5136 100644 --- a/app/containers/MessageComposer/components/CancelEdit.tsx +++ b/app/containers/MessageComposer/components/CancelEdit.tsx @@ -1,13 +1,13 @@ import { BaseButton } from './Buttons'; -import { useRoomContext } from '~/views/RoomView/context'; -import { useMessageAction } from '~/containers/message/stores/MessageActionStore'; +import { useEditCancel } from '../ComposerStore'; +import { useMessageActionKind } from '~/containers/message/stores/MessageActionStore'; import { Gap } from './Gap'; export const CancelEdit = () => { - const { editCancel } = useRoomContext(); - const action = useMessageAction(); + const editCancel = useEditCancel(); + const actionKind = useMessageActionKind(); - if (action?.kind !== 'edit') { + if (actionKind !== 'edit') { return null; } return ( diff --git a/app/containers/MessageComposer/components/ComposerInput.test.tsx b/app/containers/MessageComposer/components/ComposerInput.test.tsx new file mode 100644 index 00000000000..bdb166d76f4 --- /dev/null +++ b/app/containers/MessageComposer/components/ComposerInput.test.tsx @@ -0,0 +1,181 @@ +import { createRef } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +import { ComposerInput } from './ComposerInput'; +import { MessageComposerProvider, useAutocompleteParams } from '../context'; +import { ComposerProvider } from '../ComposerStore'; +import { createMessageActionStore, MessageActionProvider } from '~/containers/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, + roomTitle: 'Room', + sharing: false, + onRemoveQuoteMessage: jest.fn() +}; + +const renderInput = ({ + action, + sharing = false +}: { + action?: Parameters[0]['initialAction']; + sharing?: boolean; +} = {}) => { + const composerRef = createRef(); + const inputRef = createRef(); + 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 74f96cfed42..b46d25ba894 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -20,7 +20,6 @@ import sharedStyles from '~/views/Styles'; import { useTheme } from '~/theme'; import { userTyping } from '~/actions/room'; import { parseJson } from '~/lib/methods/helpers/parseJson'; -import { getRoomTitle } from '~/lib/methods/helpers/helpers'; import { isTablet } from '~/lib/methods/helpers/deviceInfo'; import { MAX_HEIGHT, @@ -32,8 +31,8 @@ import { import database from '~/lib/database'; import Navigation from '~/lib/navigation/appNavigation'; import { emitter } from '~/lib/methods/helpers/emitter'; -import { useRoomContext } from '~/views/RoomView/context'; -import { useMessageAction } from '~/containers/message/stores/MessageActionStore'; +import { useComposerRid, useComposerRoomTitle, useComposerSharing, useComposerTmid, useComposerType } from '../ComposerStore'; +import { useMessageAction, useMessageActionStoreApi } from '~/containers/message/stores/MessageActionStore'; import { getMessageById } from '~/lib/database/services/Message'; import { generateTriggerId } from '~/lib/methods/actions'; import { executeCommandPreview } from '~/lib/services/restApi'; @@ -51,7 +50,12 @@ const defaultSelection: IInputSelection = { start: 0, end: 0 }; export const ComposerInput = memo( forwardRef(({ inputRef }, ref) => { const { colors, theme } = useTheme(); - const { rid, tmid, sharing, setQuotesAndText, room } = useRoomContext(); + const rid = useComposerRid(); + const tmid = useComposerTmid(); + const sharing = useComposerSharing(); + const messageActionStore = useMessageActionStoreApi(); + const roomTitle = useComposerRoomTitle(); + const t = useComposerType(); const action = useMessageAction(); const focused = useFocused(); const { setFocused, setMicOrSend, setAutocompleteParams } = useMessageComposerApi(); @@ -63,8 +67,8 @@ export const ComposerInput = memo( const isMasterDetail = useMasterDetail(); const altTextSupported = useAltTextSupported(); let placeholder = tmid ? I18n.t('Add_thread_reply') : ''; - if (room && !tmid) { - placeholder = I18n.t('Message_roomname', { roomName: (room.t === 'd' ? '@' : '#') + getRoomTitle(room) }); + if (!tmid) { + placeholder = I18n.t('Message_roomname', { roomName: (t === 'd' ? '@' : '#') + roomTitle }); if (!isTablet && placeholder.length > COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH) { placeholder = `${placeholder.slice(0, COMPOSER_INPUT_PLACEHOLDER_MAX_LENGTH)}...`; } @@ -87,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); } @@ -266,11 +272,11 @@ export const ComposerInput = memo( const { start, end } = selectionRef.current; const cursor = Math.max(start, end); const regexp = getMentionRegexp(); - let result = text.substr(0, cursor).replace(regexp, ''); + let textBeforeMention = text.substr(0, cursor).replace(regexp, ''); // Remove the ! after select the canned response if (item.type === '!') { const lastIndexOfExclamation = text.lastIndexOf('!', cursor); - result = text.substr(0, lastIndexOfExclamation).replace(regexp, ''); + textBeforeMention = text.substr(0, lastIndexOfExclamation).replace(regexp, ''); } let mention = ''; switch (item.type) { @@ -292,9 +298,9 @@ export const ComposerInput = memo( default: mention = ''; } - const newText = `${result}${mention} ${text.slice(cursor)}`; + const newText = `${textBeforeMention}${mention} ${text.slice(cursor)}`; - const newCursor = result.length + mention.length + 1; + const newCursor = textBeforeMention.length + mention.length + 1; setInput(newText, { start: newCursor, end: newCursor }); focus(); requestAnimationFrame(() => { @@ -356,7 +362,7 @@ export const ComposerInput = memo( setAutocompleteParams({ text: autocompleteText, type: ':' }); return; } - if (lastWord.match(/^!/) && room?.t === 'l') { + if (lastWord.match(/^!/) && t === 'l') { setAutocompleteParams({ text: autocompleteText, type: '!' }); return; } diff --git a/app/containers/MessageComposer/components/Quotes/Quote.tsx b/app/containers/MessageComposer/components/Quotes/Quote.tsx index cdb3e2987be..f2b231170cb 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 { useRoomContext } from '~/views/RoomView/context'; +import { useComposerTmid, useOnRemoveQuoteMessage } from '~/containers/MessageComposer/ComposerStore'; import { BaseButton } from '../Buttons'; import { useMessage } from '~/containers/MessageComposer/hooks'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; @@ -11,9 +11,10 @@ import { MarkdownPreview } from '~/containers/markdown'; export const Quote = ({ messageId }: { messageId: string }) => { const [styles, colors] = useStyle(); - const { tmid, onRemoveQuoteMessage } = useRoomContext(); + const tmid = useComposerTmid(); const message = useMessage(messageId, tmid); const useRealName = useAppSelector(({ settings }) => settings.UI_Use_Real_Name); + const onRemoveQuoteMessage = useOnRemoveQuoteMessage(); let username = ''; let msg = ''; diff --git a/app/containers/MessageComposer/components/Quotes/Quotes.tsx b/app/containers/MessageComposer/components/Quotes/Quotes.tsx index d78a0b0124a..41d067717de 100644 --- a/app/containers/MessageComposer/components/Quotes/Quotes.tsx +++ b/app/containers/MessageComposer/components/Quotes/Quotes.tsx @@ -2,11 +2,10 @@ import { useEffect, useRef, type ReactElement } from 'react'; import { FlatList } from 'react-native'; import { Quote } from './Quote'; -import { useMessageAction } from '~/containers/message/stores/MessageActionStore'; +import { useQuotedMessageIds } from '~/containers/message/stores/MessageActionStore'; export const Quotes = (): ReactElement | null => { - const action = useMessageAction(); - const selectedMessages = action?.kind === 'quote' ? action.messageIds : []; + const selectedMessages = useQuotedMessageIds(); const nQuotesRef = useRef(0); const listRef = useRef(null); @@ -19,7 +18,7 @@ export const Quotes = (): ReactElement | null => { nQuotesRef.current = selectedMessages.length; }, [selectedMessages.length]); - if (action?.kind !== 'quote') { + if (!selectedMessages.length) { return null; } diff --git a/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx b/app/containers/MessageComposer/components/RecordAudio/RecordAudio.tsx index 9e7aa78e267..773fab2203f 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 '~/lib/c import { useAppSelector } from '~/lib/hooks/useAppSelector'; import log from '~/lib/methods/helpers/log'; import { type IUpload } from '~/definitions'; -import { useRoomContext } from '~/views/RoomView/context'; +import { useComposerRid, useComposerTmid } from '~/containers/MessageComposer/ComposerStore'; import { useCanUploadFile } from '~/containers/MessageComposer/hooks'; import { Duration, type IDurationRef } from './Duration'; import AudioPlayer from '~/containers/AudioPlayer'; @@ -30,7 +30,8 @@ export const RecordAudio = (): ReactElement | null => { const numberOfTriesRef = useRef(0); const [status, setStatus] = useState<'recording' | 'reviewing'>('recording'); const { setRecordingAudio } = useMessageComposerApi(); - const { rid, tmid } = useRoomContext(); + const rid = useComposerRid(); + const tmid = useComposerTmid(); const server = useAppSelector(state => state.server.server); const user = useAppSelector(state => ({ id: state.login.user.id, token: state.login.user.token }), shallowEqual); const permissionToUpload = useCanUploadFile(rid); diff --git a/app/containers/MessageComposer/components/SendThreadToChannel.tsx b/app/containers/MessageComposer/components/SendThreadToChannel.tsx index 42ad7022b7f..416cc6e4b23 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 { useRoomContext } from '~/views/RoomView/context'; +import { useComposerTmid } from '../ComposerStore'; import { useAlsoSendThreadToChannel, useMessageComposerApi } from '../context'; import { CustomIcon } from '~/containers/CustomIcon'; import { useTheme } from '~/theme'; @@ -19,7 +19,7 @@ export const SendThreadToChannel = (): ReactElement | null => { const alsoSendThreadToChannel = useAlsoSendThreadToChannel(); const { setAlsoSendThreadToChannel } = useMessageComposerApi(); const { showEmojiSearchbar } = useEmojiKeyboard(); - const { tmid } = useRoomContext(); + const tmid = useComposerTmid(); const { colors } = useTheme(); const subscription = useRef(null); const alsoSendThreadToChannelUserPref = useAppSelector(state => state.login.user.alsoSendThreadToChannel); diff --git a/app/containers/MessageComposer/components/Toolbar/Default.tsx b/app/containers/MessageComposer/components/Toolbar/Default.tsx index af3845ea0a3..01e83e8f151 100644 --- a/app/containers/MessageComposer/components/Toolbar/Default.tsx +++ b/app/containers/MessageComposer/components/Toolbar/Default.tsx @@ -5,11 +5,11 @@ import { ActionsButton, BaseButton } from '../Buttons'; import { useMessageComposerApi } from '~/containers/MessageComposer/context'; import { Gap } from '../Gap'; import { emitter } from '~/lib/methods/helpers/emitter'; -import { useRoomContext } from '~/views/RoomView/context'; +import { useComposerSharing } from '~/containers/MessageComposer/ComposerStore'; import { useEmojiKeyboard } from '~/containers/MessageComposer/hooks/useEmojiKeyboard'; export const Default = (): ReactElement | null => { - const { sharing } = useRoomContext(); + const sharing = useComposerSharing(); const { setMarkdownToolbar } = useMessageComposerApi(); const { openEmojiKeyboard } = useEmojiKeyboard(); diff --git a/app/containers/MessageComposer/components/Unfocused/Left.tsx b/app/containers/MessageComposer/components/Unfocused/Left.tsx index 3d67e07a45c..6c90631df77 100644 --- a/app/containers/MessageComposer/components/Unfocused/Left.tsx +++ b/app/containers/MessageComposer/components/Unfocused/Left.tsx @@ -5,10 +5,10 @@ import { useFocused } from '~/containers/MessageComposer/context'; import { useEmojiKeyboard } from '~/containers/MessageComposer/hooks/useEmojiKeyboard'; import { ActionsButton } from '../Buttons'; import { MIN_HEIGHT } from '~/containers/MessageComposer/constants'; -import { useRoomContext } from '~/views/RoomView/context'; +import { useComposerSharing } from '~/containers/MessageComposer/ComposerStore'; export const Left = (): ReactElement | null => { - const { sharing } = useRoomContext(); + const sharing = useComposerSharing(); const focused = useFocused(); const { showEmojiKeyboard, showEmojiSearchbar } = useEmojiKeyboard(); if (focused || showEmojiKeyboard || showEmojiSearchbar || sharing) { diff --git a/app/containers/MessageComposer/context.tsx b/app/containers/MessageComposer/context.tsx index 19e5e28fa01..e3e289ed647 100644 --- a/app/containers/MessageComposer/context.tsx +++ b/app/containers/MessageComposer/context.tsx @@ -84,8 +84,10 @@ export const useComposerAttachments = (): State['attachments'] => useComposerSto type TMessageInnerContext = { sendMessage(): void; onEmojiSelected(emoji: IEmoji): void; - // TODO: action should be required - closeEmojiKeyboardAndAction(action?: Function, params?: any): 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 e76e7c4d60f..4c3016943e9 100644 --- a/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts +++ b/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts @@ -2,13 +2,14 @@ import { useRoute } from '@react-navigation/native'; import { useCallback, useEffect, useRef } from 'react'; import { saveDraftMessage } from '~/lib/methods/draftMessage'; -import { useRoomContext } from '~/views/RoomView/context'; +import { useComposerRid, useComposerTmid } from '../ComposerStore'; import { useMessageAction } from '~/containers/message/stores/MessageActionStore'; import { useFocused } from '../context'; export const useAutoSaveDraft = (text = '') => { const route = useRoute(); - const { rid, tmid } = useRoomContext(); + const rid = useComposerRid(); + const tmid = useComposerTmid(); const action = useMessageAction(); const focused = useFocused(); const oldText = useRef(''); diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx index 5fed009d6d5..883d0c277dd 100644 --- a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx +++ b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx @@ -11,15 +11,14 @@ jest.mock('~/lib/hooks/useAppSelector', () => ({ })); jest.mock('../context', () => ({ - useMessageComposerApi: jest.fn() -})); - -jest.mock('~/views/RoomView/context', () => ({ - useRoomContext: jest.fn() + useMessageComposerApi: jest.fn(), + MessageInnerContext: require('react').createContext({ getText: jest.fn(() => 'draft'), setInput: jest.fn() }) })); jest.mock('~/containers/message/stores/MessageActionStore', () => ({ - useMessageAction: jest.fn() + useMessageActionKind: jest.fn(), + useQuotedMessageIds: jest.fn(() => []), + useMessageActionStoreApi: jest.fn(() => ({ getState: () => ({ actions: { setQuoteMessageIds: jest.fn() } }) })) })); jest.mock('~/lib/hooks/useAltTextSupported', () => ({ @@ -49,8 +48,8 @@ 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 mockUseRoomContext = require('~/views/RoomView/context').useRoomContext as jest.Mock; -const mockUseMessageAction = require('~/containers/message/stores/MessageActionStore').useMessageAction as jest.Mock; +const mockUseMessageActionKind = require('~/containers/message/stores/MessageActionStore').useMessageActionKind as jest.Mock; +const mockUseQuotedMessageIds = require('~/containers/message/stores/MessageActionStore').useQuotedMessageIds as jest.Mock; const mockUseAltTextSupported = require('~/lib/hooks/useAltTextSupported').useAltTextSupported as jest.Mock; const mockGetSubscriptionByRoomId = require('~/lib/database/services/Subscription').getSubscriptionByRoomId as jest.Mock; const mockGetThreadById = require('~/lib/database/services/Thread').getThreadById as jest.Mock; @@ -71,11 +70,7 @@ describe('useChooseMedia', () => { }) ); mockUseMessageComposerApi.mockReturnValue({ addAttachments }); - mockUseRoomContext.mockReturnValue({ - setQuotesAndText: jest.fn(), - getText: jest.fn(() => 'draft') - }); - mockUseMessageAction.mockReturnValue(null); + mockUseMessageActionKind.mockReturnValue(null); mockGetSubscriptionByRoomId.mockResolvedValue({ rid: 'room-id', t: 'c' }); mockGetThreadById.mockResolvedValue({ id: 'thread-id' }); }); @@ -134,7 +129,8 @@ describe('useChooseMedia', () => { it('forwards quoted message ids to ShareView as selectedMessages', async () => { mockUseAltTextSupported.mockReturnValue(false); - mockUseMessageAction.mockReturnValue({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] }); + mockUseMessageActionKind.mockReturnValue('quote'); + mockUseQuotedMessageIds.mockReturnValue(['msg-1', 'msg-2']); mockGetDocumentAsync.mockResolvedValue({ canceled: false, assets: [{ name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }] @@ -152,7 +148,8 @@ describe('useChooseMedia', () => { it('does not quote the message when the action is edit', async () => { mockUseAltTextSupported.mockReturnValue(false); - mockUseMessageAction.mockReturnValue({ kind: 'edit', messageId: 'msg-1' }); + mockUseMessageActionKind.mockReturnValue('edit'); + mockUseQuotedMessageIds.mockReturnValue([]); mockGetDocumentAsync.mockResolvedValue({ canceled: false, assets: [{ name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }] diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.ts b/app/containers/MessageComposer/hooks/useChooseMedia.ts index 7223124ae17..16236c121dc 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,14 @@ import { getSubscriptionByRoomId } from '~/lib/database/services/Subscription'; import { getThreadById } from '~/lib/database/services/Thread'; import Navigation from '~/lib/navigation/appNavigation'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; -import { useRoomContext } from '~/views/RoomView/context'; -import { useMessageAction } from '~/containers/message/stores/MessageActionStore'; +import { + useMessageActionKind, + useMessageActionStoreApi, + useQuotedMessageIds +} from '~/containers/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,8 +34,10 @@ export const useChooseMedia = ({ }) => { const { FileUpload_MediaTypeWhiteList, FileUpload_MaxFileSize } = useAppSelector(state => state.settings); const { addAttachments } = useMessageComposerApi(); - const { setQuotesAndText, getText } = useRoomContext(); - const action = useMessageAction(); + const { getText, setInput } = useContext(MessageInnerContext); + const actionKind = useMessageActionKind(); + const messageActionStore = useMessageActionStoreApi(); + const quotedMessageIds = useQuotedMessageIds(); const altTextSupported = useAltTextSupported(); const allowList = FileUpload_MediaTypeWhiteList as string; const maxFileSize = FileUpload_MaxFileSize as number; @@ -93,15 +99,17 @@ export const useChooseMedia = ({ }; const startShareView = () => { - const text = getText?.() || ''; - const selectedMessages = action?.kind === 'quote' ? action.messageIds : []; + const text = getText() || ''; return { - selectedMessages, + 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; @@ -116,7 +124,7 @@ export const useChooseMedia = ({ room, thread: thread || tmid, attachments, - action: action?.kind ?? null, + action: actionKind, finishShareView, startShareView }); 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 08eb216d93f..6c8b04d6bfe 100644 --- a/app/containers/MessageComposer/index.tsx +++ b/app/containers/MessageComposer/index.tsx @@ -1,3 +1,3 @@ export * from './interfaces'; export * from './MessageComposerContainer'; -export { ComposerAttachments } from './components/Attachments/ComposerAttachments'; +export * from './ComposerStore'; diff --git a/app/containers/UIKit/UiKitModal.stories.tsx b/app/containers/UIKit/UiKitModal.stories.tsx index 907e0b79b23..edcbc5e080a 100644 --- a/app/containers/UIKit/UiKitModal.stories.tsx +++ b/app/containers/UIKit/UiKitModal.stories.tsx @@ -11,7 +11,7 @@ export default { title: 'UIKit/UiKitModal', decorators: [ (Story: any) => ( - + diff --git a/app/containers/message/__tests__/testHelpers.tsx b/app/containers/message/__tests__/testHelpers.tsx index aaaadc0b3b5..65bd2d520d4 100644 --- a/app/containers/message/__tests__/testHelpers.tsx +++ b/app/containers/message/__tests__/testHelpers.tsx @@ -10,6 +10,7 @@ import { MessageRoomProvider, type MessageRoomState } from '../stores/MessageRoo interface IMessageProvidersOptions { item?: TAnyMessageModel; previousItem?: TAnyMessageModel; + lastSeen?: Date | null; room?: Partial; withRedux?: boolean; } @@ -17,6 +18,7 @@ interface IMessageProvidersOptions { export const MessageProviders = ({ item, previousItem, + lastSeen, room, withRedux = true, children @@ -26,7 +28,7 @@ export const MessageProviders = ({ let tree: ReactNode = children; if (item) { tree = ( - + {tree} ); diff --git a/app/containers/message/components/Blocks.ts b/app/containers/message/components/Blocks.ts index 0d5c26bbd29..4711c262c09 100644 --- a/app/containers/message/components/Blocks.ts +++ b/app/containers/message/components/Blocks.ts @@ -1,7 +1,7 @@ import { createElement } from 'react'; import { messageBlockWithContext } from '~/containers/UIKit/MessageBlock'; -import { useBlockAction, useRid } from '../stores/MessageRoomStore'; +import { useRid, useBlockAction } from '../stores/MessageRoomStore'; import { useBlocks } from '../stores/MessageStore'; const Blocks = () => { diff --git a/app/containers/message/components/MessageA11yIndex.tsx b/app/containers/message/components/MessageA11yIndex.tsx new file mode 100644 index 00000000000..ceea5979676 --- /dev/null +++ b/app/containers/message/components/MessageA11yIndex.tsx @@ -0,0 +1,42 @@ +import { type ReactElement, type ReactNode } from 'react'; +import { View, type StyleProp, type ViewStyle } from 'react-native'; +import { A11y } from 'react-native-a11y-order'; + +import { useA11yGate } from '../stores/A11yGate'; + +interface IMessageA11yIndexProps { + index: number; + accessible?: boolean; + accessibilityLabel?: string; + accessibilityLanguage?: string; + style?: StyleProp; + children: ReactNode; +} + +const MessageA11yIndex = ({ + index, + accessible, + accessibilityLabel, + accessibilityLanguage, + style, + children +}: IMessageA11yIndexProps): ReactElement => { + const enabled = useA11yGate(); + + if (!enabled) { + return style ? {children} : <>{children}; + } + + return ( + + {children} + + ); +}; + +export default MessageA11yIndex; diff --git a/app/containers/message/components/MessageA11yOrder.tsx b/app/containers/message/components/MessageA11yOrder.tsx new file mode 100644 index 00000000000..3ddfda0d60b --- /dev/null +++ b/app/containers/message/components/MessageA11yOrder.tsx @@ -0,0 +1,14 @@ +import { type ReactElement, type ReactNode } from 'react'; +import { A11y } from 'react-native-a11y-order'; + +import { useA11yGate } from '../stores/A11yGate'; + +const MessageA11yOrder = ({ children }: { children: ReactNode }): ReactElement => { + const enabled = useA11yGate(); + + if (!enabled) return <>{children}; + + return {children}; +}; + +export default MessageA11yOrder; diff --git a/app/containers/message/components/MessageAccessibleIndex.tsx b/app/containers/message/components/MessageAccessibleIndex.tsx index 497378683e8..39e4a49146b 100644 --- a/app/containers/message/components/MessageAccessibleIndex.tsx +++ b/app/containers/message/components/MessageAccessibleIndex.tsx @@ -1,23 +1,23 @@ import { type ReactNode } from 'react'; import { type StyleProp, type ViewStyle } from 'react-native'; -import { A11y } from 'react-native-a11y-order'; import { useMessageText } from '../stores/MessageStore'; import { useAutoTranslate } from '../stores/MessageRoomStore'; +import MessageA11yIndex from './MessageA11yIndex'; const MessageAccessibleIndex = ({ style, children }: { style?: StyleProp; children: ReactNode }) => { const { messageText, isTranslated } = useMessageText(); const { autoTranslateLanguage } = useAutoTranslate(); return ( - {children} - + ); }; diff --git a/app/containers/message/components/Preview.tsx b/app/containers/message/components/Preview.tsx index d3805613e8b..59fdcb16758 100644 --- a/app/containers/message/components/Preview.tsx +++ b/app/containers/message/components/Preview.tsx @@ -1,24 +1,15 @@ -import { shallowEqual } from 'react-redux'; - import Message from '../index'; import { MessageRoomProvider } from '../stores/MessageRoomStore'; -import { useAppSelector } from '~/lib/hooks/useAppSelector'; -import { getUserSelector } from '~/selectors/login'; +import { A11yGateProvider } from '../stores/A11yGate'; import { type TAnyMessageModel } from '~/definitions'; const MessagePreview = ({ message }: { message: TAnyMessageModel }) => { - const { user, baseUrl } = useAppSelector( - state => ({ - user: getUserSelector(state), - baseUrl: state.server.server - }), - shallowEqual - ); - return ( - - - + + + + + ); }; diff --git a/app/containers/message/components/Reactions.tsx b/app/containers/message/components/Reactions.tsx index c177503e5e0..8cd6853de79 100644 --- a/app/containers/message/components/Reactions.tsx +++ b/app/containers/message/components/Reactions.tsx @@ -8,7 +8,7 @@ import Emoji from './Emoji'; import { BUTTON_HIT_SLOP } from '../utils'; import { useTheme } from '~/theme'; import { useMessageId, useMessageItem, useReactions } from '../stores/MessageStore'; -import { useMessageUser, useOnReactionLongPress, useOnReactionPress, useReactionInit } from '../stores/MessageRoomStore'; +import { useMessageUser, useReactionInit, useOnReactionPress, useOnReactionLongPress } from '../stores/MessageRoomStore'; interface IReaction { _id: string; diff --git a/app/containers/message/components/Thread.tsx b/app/containers/message/components/Thread.tsx index 14dda1615e0..01965c8ae49 100644 --- a/app/containers/message/components/Thread.tsx +++ b/app/containers/message/components/Thread.tsx @@ -13,10 +13,10 @@ const Thread = () => { const item = useMessageItem(); const isThreadRoom = useIsThreadRoom(); const threadBadgeColor = useThreadBadgeColor(); + const onThreadPress = useOnThreadPress(); const toggleFollowThread = useToggleFollowThread(); const user = useMessageUser(); const replies = useReplies(); - const onThreadPress = useOnThreadPress(); const { tcount, tlm, id } = useThreadData(); const { messageText } = useMessageText(); diff --git a/app/containers/message/components/Touchable/MessageTouchable.tsx b/app/containers/message/components/Touchable/MessageTouchable.tsx index 9e7e47861db..c7c684c8e25 100644 --- a/app/containers/message/components/Touchable/MessageTouchable.tsx +++ b/app/containers/message/components/Touchable/MessageTouchable.tsx @@ -1,6 +1,6 @@ -import { A11y } from 'react-native-a11y-order'; - import { useTheme } from '~/theme'; +import MessageA11yOrder from '../MessageA11yOrder'; +import MessageA11yIndex from '../MessageA11yIndex'; import Touch from './Touch'; import Message, { type TMessageProps } from '../Message/Message'; import { useLastFocusedMessageRef } from '~/lib/a11y/useLastFocusedMessageRef'; @@ -10,7 +10,7 @@ import { useMessageAccessibilityHint } from '~/containers/message/hooks/useMessa import { useIsBeingEdited } from '~/containers/message/stores/MessageActionStore'; import { useIsInfoMessage, - useMessageField, + useMessageId, useMessageLongPress, useMessagePress, useMessageStatus, @@ -23,7 +23,7 @@ const MessageTouchable = (props: TMessageProps) => { const isInfo = useIsInfoMessage(); const { hasError } = useMessageStatus(); const { tappable } = useMessageTouchable(); - const id = useMessageField(item => item.id); + const id = useMessageId(); const isBeingEdited = useIsBeingEdited(id); const onPressAction = useMessagePress(); const onLongPress = useMessageLongPress(); @@ -41,9 +41,9 @@ const MessageTouchable = (props: TMessageProps) => { if (hasError || isInfo) { return ( - + - + ); } @@ -53,8 +53,8 @@ const MessageTouchable = (props: TMessageProps) => { }; return ( - - + + { }}> - - + + ); }; diff --git a/app/containers/message/components/__tests__/Blocks.test.tsx b/app/containers/message/components/__tests__/Blocks.test.tsx index f109cf033a0..87d4e0a8be6 100644 --- a/app/containers/message/components/__tests__/Blocks.test.tsx +++ b/app/containers/message/components/__tests__/Blocks.test.tsx @@ -56,7 +56,7 @@ describe('Blocks', () => { it("calls blockAction with the wired params and rid defaulted to '' when absent", async () => { const blockAction = jest.fn(); - renderBlocks([{ appId: 'app-1' }] as TAnyMessageModel['blocks'], { blockAction }); + renderBlocks([{ appId: 'app-1' }] as TAnyMessageModel['blocks'], { handlers: { blockAction } }); const { action } = messageBlockWithContext.mock.calls[0][0]; await action({ actionId: 'submit', value: 'v', blockId: 'block-1' }); diff --git a/app/containers/message/components/__tests__/CollapsibleQuote.test.tsx b/app/containers/message/components/__tests__/CollapsibleQuote.test.tsx index c1f722084e0..5601d9e487b 100644 --- a/app/containers/message/components/__tests__/CollapsibleQuote.test.tsx +++ b/app/containers/message/components/__tests__/CollapsibleQuote.test.tsx @@ -38,7 +38,6 @@ const initialMockedStoreState = () => { initialMockedStoreState(); const contextValue: Partial = { - user: { username: 'Marcos' }, timeFormat: 'LT' }; diff --git a/app/containers/message/components/__tests__/Content.test.tsx b/app/containers/message/components/__tests__/Content.test.tsx index 2f37bdfae49..bc4b9ff0c97 100644 --- a/app/containers/message/components/__tests__/Content.test.tsx +++ b/app/containers/message/components/__tests__/Content.test.tsx @@ -53,8 +53,7 @@ const buildItem = ( const tree = (overrides: TOverrides) => { const { msg, attachments, isEncrypted, autoTranslateLanguage, tmid, isIgnored } = overrides; const contextValue: Partial = { - user: { username: 'john' }, - navToRoomInfo: jest.fn(), + handlers: { navToRoomInfo: jest.fn() }, autoTranslateLanguage }; return ( diff --git a/app/containers/message/components/__tests__/ImageContainer.test.tsx b/app/containers/message/components/__tests__/ImageContainer.test.tsx index 12b999fb11e..b120e62f1db 100644 --- a/app/containers/message/components/__tests__/ImageContainer.test.tsx +++ b/app/containers/message/components/__tests__/ImageContainer.test.tsx @@ -36,10 +36,7 @@ jest.mock('~/lib/hooks/useAltTextSupported', () => ({ const renderImageContainer = (props?: Partial>) => { const id = 'message-id'; - const contextValue: Partial = { - baseUrl: 'https://open.rocket.chat', - user: { id: 'user-id', username: 'rocket.cat', token: 'token' } - }; + const contextValue: Partial = {}; const item = { id } as unknown as TAnyMessageModel; return render( diff --git a/app/containers/message/components/__tests__/JitsiLayout.test.tsx b/app/containers/message/components/__tests__/JitsiLayout.test.tsx index 86bc0e30805..22028699705 100644 --- a/app/containers/message/components/__tests__/JitsiLayout.test.tsx +++ b/app/containers/message/components/__tests__/JitsiLayout.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react-native'; import JitsiLayout from '../Layout/JitsiLayout'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; -import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; +import { MessageRoomProvider } from '~/containers/message/stores/MessageRoomStore'; import { mockedStore } from '~/reducers/mockedStore'; import { type TAnyMessageModel } from '~/definitions'; import I18n from '~/i18n'; @@ -15,18 +15,16 @@ const item = { u: { _id: 'author-id', username: 'diego.mello' } } as unknown as TAnyMessageModel; -const renderJitsiLayout = () => { - const room: Partial = { handleEnterCall: jest.fn() }; - return render( +const renderJitsiLayout = () => + render( - + ); -}; describe('JitsiLayout', () => { test('renders the localized "started a call" line AND the join button for a jitsi_call_started message', () => { diff --git a/app/containers/message/components/__tests__/MessageA11yGate.test.tsx b/app/containers/message/components/__tests__/MessageA11yGate.test.tsx new file mode 100644 index 00000000000..1cca7abd600 --- /dev/null +++ b/app/containers/message/components/__tests__/MessageA11yGate.test.tsx @@ -0,0 +1,125 @@ +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; + +import { type TAnyMessageModel } from '~/definitions'; +import { A11yGateContext } from '~/containers/message/stores/A11yGate'; +import { type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; +import MessageTouchable from '../Touchable/MessageTouchable'; +import MessageAccessibleIndex from '../MessageAccessibleIndex'; +import { MessageProviders } from '~/containers/message/__tests__/testHelpers'; + +// Render the react-native-a11y-order Fabric views as plain testID-bearing Views so the gating +// seam is observable from the tree. This is the only thing mocked; the gate value is driven purely +// through A11yGateContext, never by mocking useIsAccessibilityNavigationEnabled. +jest.mock('react-native-a11y-order', () => { + const { View: RNView } = require('react-native'); + return { + A11y: { + Order: ({ children, ...props }: any) => ( + + {children} + + ), + Index: ({ children, index, ...props }: any) => ( + + {children} + + ) + } + }; +}); + +// Touch pulls in gesture-handler internals irrelevant to the a11y seam; render a plain wrapper instead. +jest.mock('../Touchable/Touch', () => { + const { forwardRef } = require('react'); + const { View: RNView } = require('react-native'); + return forwardRef(({ children, ...props }: any, ref: any) => ( + + {children} + + )); +}); + +const createMockMessage = (overrides: Record = {}): TAnyMessageModel => + ({ + id: 'msg-1', + msg: 'Hello World', + t: undefined, + ts: new Date(), + u: { _id: 'user-1', username: 'testuser', name: 'Test User' }, + groupable: true, + experimentalSubscribe: jest.fn(() => jest.fn()), + ...overrides + }) as unknown as TAnyMessageModel; + +const room: Partial = { rid: 'room-1' }; + +const renderTouchable = (gate: boolean, itemOverrides: Record = {}) => + render( + + + + + + ); + +describe('per-row a11y wrapper gating', () => { + describe('MessageTouchable — normal message', () => { + it('gate false: no A11y.Order / A11y.Index, message still renders', () => { + const { queryByTestId } = renderTouchable(false); + expect(queryByTestId('a11y-order')).toBeNull(); + expect(queryByTestId('a11y-index-1')).toBeNull(); + expect(queryByTestId('message-msg-1')).toBeTruthy(); + }); + + it('gate true: wraps in A11y.Order and A11y.Index index=1', () => { + const { getByTestId } = renderTouchable(true); + expect(getByTestId('a11y-order')).toBeTruthy(); + expect(getByTestId('a11y-index-1')).toBeTruthy(); + expect(getByTestId('message-msg-1')).toBeTruthy(); + }); + }); + + describe('MessageTouchable — info message (Order-only branch)', () => { + it('gate true: A11y.Order present, no A11y.Index', () => { + const { getByTestId, queryByTestId } = renderTouchable(true, { t: 'uj' }); + expect(getByTestId('a11y-order')).toBeTruthy(); + expect(queryByTestId('a11y-index-1')).toBeNull(); + }); + }); + + describe('MessageAccessibleIndex — index=2', () => { + const renderIndex = (gate: boolean, style?: object) => + render( + + + + child + + + + ); + + it('gate false with style: plain View keeps the style, no A11y.Index', () => { + const { queryByTestId, getByTestId, toJSON } = renderIndex(false, { flex: 1 }); + expect(queryByTestId('a11y-index-2')).toBeNull(); + expect(getByTestId('index-child')).toBeTruthy(); + expect(toJSON()).toMatchObject({ type: 'View', props: { style: { flex: 1 } } }); + }); + + it('gate false unstyled: child renders directly, no wrapper', () => { + const { queryByTestId, getByTestId } = renderIndex(false); + expect(queryByTestId('a11y-index-2')).toBeNull(); + expect(getByTestId('index-child')).toBeTruthy(); + }); + + it('gate true: A11y.Index index=2 forwards translation-aware props and style', () => { + const { getByTestId } = renderIndex(true, { flex: 1 }); + const index = getByTestId('a11y-index-2'); + expect(index.props.accessibilityLanguage).toBe('pt-BR'); + expect(index.props.style).toEqual({ flex: 1 }); + }); + }); +}); diff --git a/app/containers/message/components/__tests__/Quote.test.tsx b/app/containers/message/components/__tests__/Quote.test.tsx index a7c3137674d..541d8827cf5 100644 --- a/app/containers/message/components/__tests__/Quote.test.tsx +++ b/app/containers/message/components/__tests__/Quote.test.tsx @@ -45,8 +45,6 @@ const buildItem = () => ({ id: 'msg-1' }) as unknown as TAnyMessageModel; const renderQuote = (attachments: IAttachment[], ctx: Partial = {}) => { const contextValue: Partial = { - user: { id: 'user-1', username: 'john', token: 'token' }, - baseUrl: 'https://open.rocket.chat', timeFormat: 'HH:mm', ...ctx }; @@ -79,10 +77,7 @@ describe('Quote', () => { rerender( - + diff --git a/app/containers/message/components/__tests__/Reactions.test.tsx b/app/containers/message/components/__tests__/Reactions.test.tsx index 30588fb58b4..90265033274 100644 --- a/app/containers/message/components/__tests__/Reactions.test.tsx +++ b/app/containers/message/components/__tests__/Reactions.test.tsx @@ -4,7 +4,7 @@ import Reactions from '../Reactions'; import { MessageProviders } from '~/containers/message/__tests__/testHelpers'; import { setUser } from '~/actions/login'; import { mockedStore } from '~/reducers/mockedStore'; -import { type IReaction, type TAnyMessageModel } from '~/definitions'; +import { type IReaction, type IUseRoomMessageHandlersResult, type TAnyMessageModel } from '~/definitions'; const initialMockedStoreState = () => { mockedStore.dispatch( @@ -53,10 +53,11 @@ const TestWrapper = ({ item, onReactionPress }: { item: FakeItem; onReactionPres diff --git a/app/containers/message/components/__tests__/RepliedThread.test.tsx b/app/containers/message/components/__tests__/RepliedThread.test.tsx index a4fb472ccbe..832867b51ae 100644 --- a/app/containers/message/components/__tests__/RepliedThread.test.tsx +++ b/app/containers/message/components/__tests__/RepliedThread.test.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux'; import RepliedThread from '../RepliedThread'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; -import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; +import { MessageRoomProvider } from '~/containers/message/stores/MessageRoomStore'; import { type TAnyMessageModel } from '~/definitions'; import { mockedStore } from '~/reducers/mockedStore'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '~/lib/constants/keys'; @@ -11,10 +11,10 @@ import { E2E_MESSAGE_TYPE, E2E_STATUS } from '~/lib/constants/keys'; const buildItem = (overrides: Partial = {}): TAnyMessageModel => ({ id: 'msg1', tmid: 'thread1', tmsg: 'original reply', ...overrides }) as unknown as TAnyMessageModel; -const renderRepliedThread = (item: TAnyMessageModel, roomState: Partial = {}) => +const renderRepliedThread = (item: TAnyMessageModel, fetchThreadName?: jest.Mock) => render( - + @@ -34,7 +34,7 @@ describe('RepliedThread', () => { test('fetches and renders the thread name when tmsg is missing', async () => { const fetchThreadName = jest.fn().mockResolvedValue('fetched thread name'); const item = buildItem({ tmsg: undefined }); - const { getByTestId } = renderRepliedThread(item, { fetchThreadName }); + const { getByTestId } = renderRepliedThread(item, fetchThreadName); await waitFor(() => expect(getByTestId('message-thread-replied-on-fetched thread name')).toBeTruthy()); expect(fetchThreadName).toHaveBeenCalledWith('thread1', 'msg1'); @@ -43,14 +43,14 @@ describe('RepliedThread', () => { test('re-fetches when tmid/id change without remounting', async () => { const fetchThreadName = jest.fn().mockResolvedValueOnce('first thread name').mockResolvedValueOnce('second thread name'); const item = buildItem({ tmid: 'thread1', id: 'msg1', tmsg: undefined }); - const { getByTestId, rerender } = renderRepliedThread(item, { fetchThreadName }); + const { getByTestId, rerender } = renderRepliedThread(item, fetchThreadName); await waitFor(() => expect(getByTestId('message-thread-replied-on-first thread name')).toBeTruthy()); const updatedItem = buildItem({ tmid: 'thread2', id: 'msg2', tmsg: undefined }); rerender( - + diff --git a/app/containers/message/components/__tests__/Reply.test.tsx b/app/containers/message/components/__tests__/Reply.test.tsx index 602da489af8..9aa1eb9033f 100644 --- a/app/containers/message/components/__tests__/Reply.test.tsx +++ b/app/containers/message/components/__tests__/Reply.test.tsx @@ -5,6 +5,8 @@ import Reply from '../Attachments/Reply'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { mockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; +import { selectServerSuccess } from '~/actions/server'; import { type IAttachment, type TAnyMessageModel } from '~/definitions'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '~/lib/constants/keys'; import { fileDownloadAndPreview } from '~/lib/methods/helpers/fileDownload'; @@ -62,8 +64,6 @@ const renderReply = ({ ctx?: Partial; }) => { const contextValue: Partial = { - user: { id: 'user-1', username: 'john', token: 'token' }, - baseUrl: 'https://open.rocket.chat', timeFormat: 'HH:mm', ...ctx }; @@ -78,6 +78,9 @@ const renderReply = ({ ); }; +mockedStore.dispatch(setUser({ id: 'user-1', username: 'john', token: 'token' })); +mockedStore.dispatch(selectServerSuccess({ server: 'https://open.rocket.chat', version: '', name: '' })); + describe('Reply', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/containers/message/components/__tests__/Thread.test.tsx b/app/containers/message/components/__tests__/Thread.test.tsx index 3592a9b64fa..69e29756b04 100644 --- a/app/containers/message/components/__tests__/Thread.test.tsx +++ b/app/containers/message/components/__tests__/Thread.test.tsx @@ -8,9 +8,7 @@ import { type TAnyMessageModel } from '~/definitions'; import { mockedStore } from '~/reducers/mockedStore'; const baseContextValue: Partial = { - toggleFollowThread: jest.fn(), - user: { id: 'user1', username: 'user1' }, - onThreadPress: jest.fn() + handlers: { toggleFollowThread: jest.fn(), onThreadPress: jest.fn() } }; const buildItem = (overrides: Partial = {}): TAnyMessageModel => diff --git a/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap b/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap index f2de4ee7401..404c7f13697 100644 --- a/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap +++ b/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap @@ -9,468 +9,448 @@ exports[`Story Snapshots: Archived should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + This message is inside an archived room - - - - - - - - - - This message is inside an archived room - - - - - - + + - + - - - + + + `; @@ -484,468 +464,448 @@ exports[`Story Snapshots: ArchivedLargeFont should match snapshot 1`] = ` } > - - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - + - diego.mello + This message is inside an archived room - - - - - 10:00 AM + - - - - - - - This message is inside an archived room - - - - - - - + - - - + + + `; @@ -959,7366 +919,6665 @@ exports[`Story Snapshots: AttachmentWithTextAndLink should match snapshot 1`] = } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - + + Rocket.Chat + + + - - - - - Rocket.Chat - - - - - - - Rocket.Chat - - - - - , the best open source chat - + Rocket.Chat - - - - - - - - - - - - - + -  + { + "color": "#2F343D", + }, + ] + } + > + , the best open source chat + - - + - - - - - - - - - - -`; - -exports[`Story Snapshots: AttachmentWithTextAndLinkLargeFont should match snapshot 1`] = ` - + + + + + + +  + + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: AttachmentWithTextAndLinkLargeFont should match snapshot 1`] = ` + - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - + - - diego.mello - - - - - - 10:00 AM - - - - + + Rocket.Chat + + + - - - - - Rocket.Chat - - - - - - - Rocket.Chat - - - - - , the best open source chat - + Rocket.Chat - - - - - - - - - - - - - + -  + { + "color": "#2F343D", + }, + ] + } + > + , the best open source chat + - - + - - - - - - - - - - -`; - -exports[`Story Snapshots: Avatar should match snapshot 1`] = ` - - - - - - - - - - - + - - + - - - - - - - - - - + /> - - no.avatar - + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > - 10:00 AM +  - - - - - - - This message has no avatar. - - - - - - - + - - - - + + + + +`; + +exports[`Story Snapshots: Avatar should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + no.avatar + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM + This message has no avatar. - - - - - - - - - - This message uses the user's profile picture as the avatar. - - - - - - + + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - 😄 - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + This message uses the user's profile picture as the avatar. - - - - - - - - - - This message uses a Unicode emoji as the avatar. - - - - - - + + - + - - - - + + + - - + + + + + + + + 😄 + + + + + + + - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + This message uses a Unicode emoji as the avatar. - - - - - - - - - - This message uses a custom animated emoji as the avatar. - - - - - - + + - + - - - - + + + - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + This message uses a custom animated emoji as the avatar. - - - - - - - - - - This message uses a custom static emoji as the avatar. - - - - - - + + - + - - - - - -`; - -exports[`Story Snapshots: Basic should match snapshot 1`] = ` - - - + + + - - + - - + - - + + - + - - - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + This message uses a custom static emoji as the avatar. - - - - - - - - - - Message - - - - - - + + - + - - - - + + + + +`; + +exports[`Story Snapshots: Basic should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Message - - - - - - - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - - - + + - + - - - - - -`; - -exports[`Story Snapshots: BasicLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - - - - - - - - - - - - diego.mello + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - - 10:00 AM + - - - - - - - Message - - - - - - - + - - - - + + + + +`; + +exports[`Story Snapshots: BasicLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - diego.mello + Message - - - - - 10:00 AM + - - - - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - - - - + - - - - - -`; - -exports[`Story Snapshots: BlockQuote should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - - - - - - - - - - Testing block quote - - - - - - - - + + - + - - - - + + + + +`; + +exports[`Story Snapshots: BlockQuote should match snapshot 1`] = ` + + + - - + - + + + + + + + + + + + + - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + + - diego.mello - - - 10:00 AM - - - - - - - - - - - - - - Testing block quote - - - - - - - - - Testing block quote - + Testing block quote - + - + - - - - - -`; - -exports[`Story Snapshots: BlockQuoteLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + + - diego.mello + + + Testing block quote + + - - 10:00 AM - - - - - - - - - - - Testing block quote - - - - - - - - + Testing block quote + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: BlockQuoteLargeFont should match snapshot 1`] = ` + + + - - - + + - + + + + + + + + + + + + - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + + - diego.mello - - - - - - 10:00 AM - - - - - - - - - - - Testing block quote - - - - - - - - - Testing block quote - + Testing block quote - + - + - - - - - -`; - -exports[`Story Snapshots: Broadcast should match snapshot 1`] = ` - - - + + + - - + - - + + + + + + + + + + + + + - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + + - diego.mello - - - 10:00 AM - - - - - - - - - - Broadcasted message - + Testing block quote - + - + + - - - -  - - - Reply - - - - - + Testing block quote + + + - + - - - + + + `; -exports[`Story Snapshots: BroadcastLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: Broadcast should match snapshot 1`] = ` - - - + - - - + + + + + + + + + + + + + + - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - - diego.mello - - - - - 10:00 AM - - - - - - - - Broadcasted message - - - - - - - - - -  - - - Reply - - - - - + Broadcasted message + + + - - - - - - - - - - -`; - -exports[`Story Snapshots: CollapsedAttachments should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Message - - - - - - - + - - - - - Title collapsed - - - - - -  - - - - - + "color": "#FFFFFF", + }, + ] + } + > + Reply + - + - - - - + + + + +`; + +exports[`Story Snapshots: BroadcastLargeFont should match snapshot 1`] = ` + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + Broadcasted message - - - + + + + + + - - + - - - - - - Title collapsed - - - - - - - Title collapsed - - - - - - - - Field 1 - - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - - - - - - - - - - Message - - - - - + "color": "#FFFFFF", + }, + ] + } + > + Reply + - + - - - + + + `; -exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: CollapsedAttachments should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello + Message - - - - - 10:00 AM + + + + + @@ -12142,819 +10399,712 @@ exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1` "fontVariant": [ "tabular-nums", ], - "fontWeight": "400", - "lineHeight": 22, + "fontWeight": "500", "textAlign": "left", }, { - "color": "#2F343D", + "color": "#6C727A", }, ] } > - - - Message - - + Title collapsed - - - - - - Title collapsed - - - - - -  - - - - +  + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - diego.mello - - - - - - 10:00 AM - - - + + Title collapsed + + + + + + + Title collapsed + + + + + + + Field 1 + - - - Title collapsed - - - - - - Title collapsed - - - - - - - Field 1 + Value 1 - - - - - Value 1 - - - - - - + + + + + + Field 2 + + + + - Field 2 + Value 2 - - - - - Value 2 - - - - - - + + - + + + + + + - - - - - Message - - - - - - + Message + + + - + - - - + + + `; -exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1`] = ` +exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + Message + + + - - diego.mello - - - 10:00 AM - - - - - - - @@ -13631,1085 +11848,558 @@ exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1` "fontVariant": [ "tabular-nums", ], - "fontWeight": "400", - "lineHeight": 22, + "fontWeight": "500", "textAlign": "left", }, { - "color": "#2F343D", + "color": "#6C727A", }, ] } > - - - This is the main message body. - - + Title collapsed - - - - - - Collapsed attachment block - - - - - -  - - - - +  + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - + - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - Collapsed attachment block - - - - - - - This attachment text should NOT appear as plain text above the message or duplicate before the block. - - - - - - - - Field 1 - - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - Long field - - - - - - This field value could also contribute to duplicate text when expanded. - - - - - - - - + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "500", + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + ] + } + > + Title collapsed + - Attachment description that might also leak as duplicate plain text. + Title collapsed - - - - - - - This is the main message body. + Field 1 - - - - - - - - - - - - - - - - - -`; - -exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - - - - 10:00 AM - - - - - - - - This is the main message body. - - - - - - - - - @@ -15425,57 +12522,145 @@ exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match sn "fontVariant": [ "tabular-nums", ], - "fontWeight": "500", + "fontWeight": "400", + "lineHeight": 22, "textAlign": "left", }, { - "color": "#6C727A", + "color": "#2F343D", }, ] } > - Collapsed attachment block + + + Value 1 + + -  + Field 2 + + + + + Value 2 + + + + @@ -15483,1552 +12668,1343 @@ exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match sn - - - - - - - - - - - - - - - - - + - + - - - - - - + "color": "#2F343D", + }, + ] + } + > + Message + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - + - diego.mello + This is the main message body. - - - - - 10:00 AM + + + + + - - - - - - Collapsed attachment block - - - - - - - This attachment text should NOT appear as plain text above the message or duplicate before the block. - - - - - - - - Field 1 - - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - Long field - - - - - - This field value could also contribute to duplicate text when expanded. - - - - - - - - - - - - - - Attachment description that might also leak as duplicate plain text. - - - - + Collapsed attachment block + - - - - - This is the main message body. - - - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - + - - - - - -`; - -exports[`Story Snapshots: ColoredAttachments should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - + + Collapsed attachment block + + + + + + + This attachment text should NOT appear as plain text above the message or duplicate before the block. + + + + + + + Field 1 + - - - - Field 1 + Value 1 - - - - - Value 1 - - - - - - + + + + + + Field 2 + + + + - Field 2 + Value 2 - - - - - Value 2 - - - - - - + + - - - - + + Long field + - - - - - Field 1 - - - - - - Value 1 - - - - - - - Field 2 + This field value could also contribute to duplicate text when expanded. - - - - - Value 2 - - - - - - + + - + + + + - - - - - - - - Field 1 - - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - - - - + Attachment description that might also leak as duplicate plain text. + + + + + + + + + This is the main message body. + + + + + - + - - - + + + `; -exports[`Story Snapshots: ColoredAttachmentsLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match snapshot 1`] = ` - - - + - + + + + + + + + + + + + - - + diego.mello + + + + + + 10:00 AM + + + + + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + This is the main message body. + + + - + + + + Collapsed attachment block + + + + - diego.mello +  - - + + + + + + + + + + + + + + + + + + + + + - 10:00 AM - - + + + + + + + + + + + diego.mello + + + + + + 10:00 AM + + + + + + + Collapsed attachment block + + + - - - - - - - Field 1 - - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - + This attachment text should NOT appear as plain text above the message or duplicate before the block. + + + - - + + Field 1 + - - - - - Field 1 - - - - - - Value 1 - - - - - - - Field 2 + Value 1 - - - - - Value 2 - - - - - - + + - - - - + + Field 2 + - - - - Field 1 + Value 2 - - - - - Value 1 - - - - - - + + + + + + Long field + + + + - Field 2 + This field value could also contribute to duplicate text when expanded. - - - - - Value 2 - - - - - - + + + + + + + Attachment description that might also leak as duplicate plain text. + + + + + + + + + + + + This is the main message body. + + + - + - - - + + + `; -exports[`Story Snapshots: CustomFields should match snapshot 1`] = ` +exports[`Story Snapshots: ColoredAttachments should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - + + > + + + Field 1 + + + + + + Value 1 + + + + + + + + Field 2 + + + + + + Value 2 + + + + + + - - + + - - - - - diego.mello - - - 10:00 AM - - - - - + + + Field 1 + - - - rocket.cat - - - - - - Custom fields - - - - - - - Field 1 + Value 1 - - - - - Value 1 - - - - - - + + + + + + Field 2 + + + + - Field 2 + Value 2 - - - - - Value 2 - - - - - - + + + + + + - + - + + Field 1 + + + + + + Value 1 + + + + + + - Message + Field 2 - - + + + + + Value 2 + + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: ColoredAttachmentsLargeFont should match snapshot 1`] = ` + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - - diego.mello - - - 10:00 AM - - - - - + + + Field 1 + - - - rocket.cat - - - - - - Custom fields - - - - - - - Field 1 + Value 1 - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - Field 3 - - - - - - Value 3 - - - - - - + + + + + + Field 2 + + + + - - Field 4 - - - - - - Value 4 - - - - - - - Field 5 + Value 2 - - - - - Value 5 - - - - - - + + + + + + - + - - Message + Field 1 - - - - - - - - - - - - - - - - - -`; - -exports[`Story Snapshots: CustomFieldsLargeFont should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - + + + + Value 1 + + + + + + + > + + Field 2 + + + + + + Value 2 + + + + + + - - + + - - - - - diego.mello - - - - - - 10:00 AM - - + + + Field 1 + - - - rocket.cat - - - - - Custom fields - + Value 1 - - - - - Field 1 - - - - - - Value 1 - - - - - - + + + + + Field 2 + + + + - Field 2 + Value 2 - - - - - Value 2 - - - - - - + + - - - - - - Message - - - - - - + - - - - + + + + +`; + +exports[`Story Snapshots: CustomFields should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - + - - - - - - - - - - - diego.mello - - - - - - 10:00 AM - - - - + > + rocket.cat + + + + + + Custom fields + + + + + + + Field 1 + - - - rocket.cat - - - - - Custom fields - + Value 1 - - + + + + + Field 2 + + + - - Field 1 + Value 2 - - - - - Value 1 - - - - - - - - Field 2 - - - - - - Value 2 - - - - - - - - Field 3 - - - - - - Value 3 - - - - - - - - Field 4 - - - - - - Value 4 - - - - - - - - Field 5 - - - - - - Value 5 - - - - - - + + - + + + + + + - - - - - Message - - - - - - + Message + + + - + - - - - - -`; - -exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = ` - - - + + + - - + - - - + + + + + + + + + + + + + + - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - rocket.cat - - - 10:00 AM - - + }, + { + "borderColor": "#CBCED1", + }, + ] + } + testID="reply-rocket.cat-Custom fields" + > - - - - + rocket.cat + + + + + + + Custom fields + + + + + + - Fourth message + Field 1 - - - - - - - - - - - - - - - - - - unread - - - - November 10, 2017 - - - - - - - - - - - - - - - - - + + + + Value 1 + + + + + + - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - + + Field 2 + + + + + + Value 2 + + + + + + - Third message + Field 3 - - - - - - - - - - - - - - - - - - unread - - - - - - - - - - - - - - - - - - - + + + + Value 3 + + + + + + + + Field 4 + + + + + + Value 4 + + + + + + - Second message + Field 5 - - + + + + + Value 5 + + + + + + + + > + + + + Message + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: CustomFieldsLargeFont should match snapshot 1`] = ` + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - - rocket.cat - - - 10:00 AM - - + }, + { + "borderColor": "#CBCED1", + }, + ] + } + testID="reply-rocket.cat-Custom fields" + > - - - - + rocket.cat + + + + + + + Custom fields + + + + + + - Second message + Field 1 - - - - - - - - - - - - - - - - - - - - November 10, 2017 - - - - - - - - - - - - - - - - - - - + + + + Value 1 + + + + + + + > + + Field 2 + + + + + + Value 2 + + + + + + - - + + - - + - diego.mello - - - 10:00 AM + Message - - - - - - - - - - First message - - - - - - + + - + - - - - - -`; - -exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot 1`] = ` - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - - rocket.cat - - - - - - 10:00 AM - - - + rocket.cat + + + + + + + Custom fields + + + + + + - Fourth message + Field 1 - - - - - - - - - - - - - - - - - - unread - - - - November 10, 2017 - - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - - - - 10:00 AM - - - - - - - - Third message - - - - - - - - - - - - - - - - - - - unread - - - - - - - - - - - - - - - - - - - + + + Value 1 + + + + + + - Second message + Field 2 - - - - - - - - + + + + Value 2 + + + + + + + + Field 3 + + + + + + Value 3 + + + + + + + + Field 4 + + + + + + Value 4 + + + + + + + + Field 5 + + + + + + Value 5 + + + + + + + + + + + + + + > + + + + Message + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = ` + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + rocket.cat + + + 10:00 AM + + + + - - + - rocket.cat + Fourth message - - - - - 10:00 AM + - - - - - - - Second message - - - - - - - + - - - + + + - + > + unread + - - November 10, 2017 - - - + + > + November 10, 2017 + - - - + - - + - - + + - - - - - - - + style={ + { + "borderRadius": 4, + "height": 36, + "width": 36, + } + } + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - + - diego.mello + Third message - - - - - 10:00 AM + - - - - - - - First message - - - - - - - + - - - - - -`; - -exports[`Story Snapshots: Discussion should match snapshot 1`] = ` - - - + + + - - + + + + + + - - + - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM + Second message - - - - - - Started a discussion: - - - This is a discussion - - - - -  - - - No messages yet - - - - - - + - + + - - - - + + + - - + + + + + + + + + + + + + - - + rocket.cat + + - + 10:00 AM + + + + + + + + - - - - - - - - - - - - - - diego.mello - - - 10:00 AM + Second message - - - - - - Started a discussion: - - - This is a discussion - - - - -  - - - 1 message - - - - - November 10, 2017 - - - + - + - - - - + + + - + - + November 10, 2017 + + + + + + + - - + - - + + - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + First message - - - - - - Started a discussion: - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - -  - - - 10 messages - - - - - November 10, 2017 - - - + - + - - - - + + + + +`; + +exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot 1`] = ` + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + rocket.cat + + + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + Fourth message - - - - - - Started a discussion: - - - This is a discussion - - - - -  - - - +999 messages - - - - - November 10, 2017 - - - + - + - - - - - -`; - -exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = ` - - - + + + - - + + + November 10, 2017 + + + + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - diego.mello + Third message - - - + + + + + + + + + + + + + + + unread + + + + + + + + + + + + + + - 10:00 AM - - - - Started a discussion: - - This is a discussion + + Second message + - - - - -  - - - No messages yet - - - - - - + - + + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + rocket.cat + + + + 10:00 AM + + + - - + - diego.mello + Second message - - - - - 10:00 AM - - - - Started a discussion: - - - This is a discussion - - - - -  - - - 1 message - - - - - November 10, 2017 - - - + - + - - - - + + + - + - + November 10, 2017 + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - diego.mello + First message - - - - - 10:00 AM - - - - Started a discussion: - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - -  - - - 10 messages - - - - - November 10, 2017 - - - + - + - - - - + + + + +`; + +exports[`Story Snapshots: Discussion should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - + + + diego.mello + + + 10:00 AM + + + + + + Started a discussion: + + + This is a discussion + + - + - - - - - - - - - - - - - - - diego.mello - - - - - - 10:00 AM - - - - Started a discussion: - - + - This is a discussion + No messages yet - - - - -  - - - +999 messages - - - - - November 10, 2017 - - + - + - - - - - -`; - -exports[`Story Snapshots: Edited should match snapshot 1`] = ` - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={36} + /> - + + + + + + + - - + diego.mello + + + 10:00 AM + + + + + + + Started a discussion: + + + This is a discussion + + + + - - - diego.mello - - - 10:00 AM - - - - + - -  - - - - - - - - - - - Message header - - - - - + 1 message + + + November 10, 2017 + - + - - - - + + + - - + - - + - - - + + - - - - - - Message without header - - - - - - + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + Started a discussion: + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - + +  + + -  - + { + "color": "#FFFFFF", + }, + ] + } + > + 10 messages + + + + November 10, 2017 + - + - - - - - -`; - -exports[`Story Snapshots: EditedLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + + Started a discussion: + + + This is a discussion + + - - - diego.mello - - - - + - -  - - - - - - 10:00 AM - - - - - - - - Message header - - - - - + +999 messages + + + November 10, 2017 + - + - - - - + + + + +`; + +exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = ` + + + - - + - - + - - - + + - - - - - - Message without header - - - - - - + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + This is a discussion + + - + -  - + [ + { + "lineHeight": 20.8, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + No messages yet + + + - + - - - - - -`; - -exports[`Story Snapshots: Editing should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + This is a discussion + + - - - diego.mello - - - 10:00 AM - - - - - - - + - - - - Message being edited - - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 12, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "lineHeight": 16, + "textAlign": "left", + }, + { + "color": "#FFFFFF", + }, + ] + } + > + 1 message + + + November 10, 2017 + - + - - - - - -`; - -exports[`Story Snapshots: EditingLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - - - diego.mello - - - - - - 10:00 AM - - - - + - - - - Message being edited - - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 12, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "lineHeight": 16, + "textAlign": "left", + }, + { + "color": "#FFFFFF", + }, + ] + } + > + 10 messages + + + November 10, 2017 + - + - - - - - -`; - -exports[`Story Snapshots: Emojis should match snapshot 1`] = ` - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + This is a discussion + + - - - diego.mello - - - 10:00 AM - - - - - - - + - - - 👊 - - - 🤙👏 - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 12, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "lineHeight": 16, + "textAlign": "left", + }, + { + "color": "#FFFFFF", + }, + ] + } + > + +999 messages + + + November 10, 2017 + - + - - - - + + + + +`; + +exports[`Story Snapshots: Edited should match snapshot 1`] = ` + + + - - + - - + - + + + + + + + + + + + + - - - - - - - - - + > + diego.mello + + + 10:00 AM + - - + + + + + + + + - diego.mello - - - 10:00 AM + Message header - - - - - - - - - 👏 - - - - - + + - + - - - - + + + - - + + - - + - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM + Message without header - - - - - - - - - - - - - - - - - - - - + + - + + + + + +  + + - - - - + + + + +`; + +exports[`Story Snapshots: EditedLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + - - +  + + + + + + 10:00 AM + + + + + + - diego.mello - - - 10:00 AM + Message header - - - - - - - - - - - - - - + + - + - - - - + + + - - + + - - - - - - - - - - - - - - - + - diego.mello - - - 10:00 AM + Message without header - - - - - - - - - 🤙 - - - - - - - - + + - + - - - - - - - - - + + +  + + + + + + + + + + +`; + +exports[`Story Snapshots: Editing should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Message being edited - - + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: EditingLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + - - - - - - - - 🤙 - - - :react_rocket: - - - 🤙 - - - 🤙 - - - - - - + transition={null} + width={46.800000000000004} + /> - - - + + + - - - - - - - -`; - -exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - diego.mello + Message being edited - - - - - 10:00 AM + - - - - - - 👊 - - - 🤙👏 - - - - - - + - - - - + + + + +`; + +exports[`Story Snapshots: Emojis should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - + - - - 10:00 AM - - - - - - - 👏 - - - - + 🤙👏 + - + - - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - - - - 10:00 AM - - - - - - - - - - - - - - - - - + 👏 + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + + + + - - diego.mello - + style={ + { + "height": 30, + "width": 30, + } + } + transition={null} + width={30} + /> - - - 10:00 AM - - - - - - - - - - + } + transition={null} + width={30} + /> - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - diego.mello - - - - - - 10:00 AM - - - - - - - 🤙 - - - - - - + transition={null} + width={30} + /> - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - + 🤙 + + - - diego.mello - - - - - - 10:00 AM - - - - - - - - 🤙 - - - :react_rocket: - - - 🤙 - - - 🤙 - - - - - + transition={null} + width={30} + /> + - + - - - - - -`; - -exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + + 🤙 + + - diego.mello + :react_rocket: + 🤙 + + - 10:00 AM + 🤙 - - - - -  - - - - - - - - Encrypted message - - - + + - + - - - - + + + + +`; + +exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - Encrypted message - - - - + diego.mello + + + + 10:00 AM + + + - + -  - + { + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 30, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "lineHeight": 43, + "textAlign": "left", + }, + ] + } + > + 👊 + + + 🤙👏 + + - + - - - - + + + - - + - - + - - + + - - - - - - - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - + 👏 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + + + 10:00 AM + + + + + + - - diego.mello - - - 10:00 AM - + transition={null} + width={30} + /> - - -  - - - - - - - - Encrypted message - + style={ + { + "height": 30, + "width": 30, + } + } + transition={null} + width={30} + /> - - - - 😂 - - - 1 - - - - + - - - - 1 - - - - - - - 🤔 - - - 1 - - - - - - -  - - - + transition={null} + width={30} + /> - + - - - - + + + - - + - + - -  - - - + + + + + + + + + - Encrypted message - + } + > - -  - + + diego.mello + + + - - + + 10:00 AM + - + - - - - - - - - - - - Encrypted message - + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - + 🤙 + + - - diego.mello - - - 10:00 AM - - - - - -  - - - - - - - - Encrypted message - + transition={null} + width={30} + /> - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + + 🤙 + + - diego.mello + :react_rocket: - 10:00 AM + 🤙 - - - - -  - - - - - - - Encrypted message + 🤙 - - + + - + - - - - - + + + + +`; + +exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` + + + + - - + - + - - - + - - - + + + + + + - - - - diego.mello - - - 10:00 AM - - - @@ -44535,14 +39958,6 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -44554,868 +39969,790 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-diego.mello" > + diego.mello + + -  + 10:00 AM + + +  + + + + + + -  + Encrypted message - - - - Encrypted message - - - - + - - + - - + + - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - -  - - - -  - - - - - - - Encrypted message - - - - + Encrypted message + - + + + + + +  + + - - - - + + + - - + + + + + + + + + + + + + - - - - + - - - Encrypted message - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "lineHeight": 18, + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + ] + } + > + 10:00 AM + + + + + -  + Encrypted message - - - - - - - - - - -`; - -exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` - - - - - - - - - - - + - + 😂 + + - - - - - - + 1 + + - - - - - diego.mello - - - + - - -  - - - - - - 10:00 AM - - - - - Encrypted message - - - - - - - - - - - - - - - - - - - - - - - - - - - - Encrypted message - - + 1 + - - - + + 🤔 + + + 1 + + + + + -  - + +  + + - + - - - - + + + - - + + + + +  + + + + Encrypted message + + + +  + + + + + + + + + + + + + + + + + + + + + Encrypted message + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + - - - - diego.mello - - - - - -  - - - - - 10:00 AM +  - + + + + + + Encrypted message + + + + + + + + + + + + + + + + + + + + - - - Encrypted message - - - + + - - - - 😂 - - - 1 - - - - - - - - 1 - - - - - - - 🤔 - - - 1 - - - - - - -  - - - - - + } + transition={null} + width={36} + /> - - - + + + - - - - - - - - - - - -  - - - - Encrypted message - - - + -  - - - - - - - + diego.mello + + - - - - - - - + 10:00 AM + - - - Encrypted message +  - + + + + + Encrypted message + + + - - - - + + + - - + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + +  + + + + +  + + + + + + + + Encrypted message + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + - - - - diego.mello - - - - - -  - - - - + } + accessible={true} + collapsable={false} + focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "paddingLeft": 5, + } + } + > - 10:00 AM +  - + - - - Encrypted message - - - - + "lineHeight": 25, + }, + { + "marginTop": -5, + "paddingLeft": 5, + }, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="read-receipt-read" + > +  + + + + + + + Encrypted message + - + - - - - + + + - - + + - - - + Encrypted message + + + + + + + + +  + + + +  + + + + + + + + + +`; + +exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + - - - - - - - - - - - - - - - diego.mello - - - - - -  - - - - - - 10:00 AM - - - - - Encrypted message - - - + transition={null} + width={46.800000000000004} + /> - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - diego.mello - - - @@ -49015,14 +44290,6 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -49034,906 +44301,1031 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-diego.mello" > -  + diego.mello + - + -  - + [ + { + "lineHeight": 20.8, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - - + 10:00 AM + + + + + Encrypted message + + + + + + + + + + + + + + + + - 10:00 AM - + } + /> + - + + Encrypted message + + + + + + + + - Encrypted message - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - + - - + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + - - - - diego.mello - - - - - -  - - - -  - - - - - 10:00 AM +  - - - - Encrypted message - - - - - - - - - - - - - - - - - + + 10:00 AM + + + testID="message-encrypted" + > + Encrypted message + + - - - Encrypted message - - + } + > + 😂 + + + 1 + - - - -  - + + + 1 + + - + + + 🤔 + + + 1 + + + + -  - - - - - - - - - - - - -`; - -exports[`Story Snapshots: Error should match snapshot 1`] = ` - - - + + +  + + + + + + + + + + + + + - + +  + + + - + Encrypted message + + + - + + + + + + - - - + - - - + + + + + + - - - diego.mello + Encrypted message - + + + + + + + + + + + + + + + + - 10:00 AM - - + + + + + + + + + + @@ -50368,14 +46372,6 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -50387,113 +46383,178 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-diego.mello" > -  + diego.mello - - - - - - - This message has error - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + 10:00 AM + + + + + Encrypted message @@ -50501,304 +46562,280 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` - + - - + - - + - + - - - + - - - + + + + + + - - - - diego.mello - - - 10:00 AM - - - @@ -50823,14 +46860,6 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -50842,62 +46871,154 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-diego.mello" > -  + diego.mello + + + +  + + + - - + > + 10:00 AM + - - - This message has error too - - + Encrypted message @@ -50956,383 +47046,467 @@ exports[`Story Snapshots: Error should match snapshot 1`] = ` - + - - - -`; - -exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = ` - - - + - - - - + - + + - - - - - - - - - - - + + + + + + + + + - + + diego.mello + + + + - + - diego.mello - - - - + + + - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + 10:00 AM + + + - 10:00 AM + Encrypted message - - - - - - - This message has error - - - - - - - + - - + - - + - + - - - + - - - + + + + + + - - - - diego.mello - - - @@ -51724,14 +47819,6 @@ exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -51743,84 +47830,184 @@ exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-diego.mello" > -  + diego.mello - - - - 10:00 AM - - + + +  + + + +  + + + + + > + 10:00 AM + - + + + + + + + + + + + + + + + + + + + + - - This message has error too - - + }, + { + "color": "#6C727A", + }, + ] + } + testID="message-encrypted" + > + Encrypted message + + + +  + + + +  + + - + - + `; -exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`] = ` +exports[`Story Snapshots: Error should match snapshot 1`] = ` - - - + + + + + + + + + + + + - - + + 10:00 AM + + + + + - + + + + + + + - - - - - - - - - - - - + - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - test.py - - - - test.py - - - - - - - - - - - - Check out this Python file - - - - - - - - - - + "color": "#2F343D", + }, + ] + } + > + This message has error + + + + - - - - + + + - - + + + + + + + + + + + + - - + - - - - - - - - - - - - - + 10:00 AM + + + + + - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - Component.tsx - - - - Component.tsx - - - - - - - - - - - - TypeScript component - - - - - - - - - - + "lineHeight": 16, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - config.json - - - - config.json - - - - - - - - - - - - Configuration file - - - - - - - - - - + This message has error too + + + + - - - - + + + + +`; + +exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + - - + + + + - + + + + + 10:00 AM + + + + + - - - - - - - - - - - - + - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - main.go - - - - - - - This is the - - - - - main - - - - - entry point for the application - - - - - - - - - - - - - - - File with description - - - - - - - - - - + "color": "#2F343D", + }, + ] + } + > + This message has error + + + + - - - - + + + - - - - - + [ + { + "backgroundColor": undefined, + "borderRadius": undefined, + "margin": undefined, + "marginBottom": undefined, + "marginEnd": undefined, + "marginHorizontal": undefined, + "marginLeft": undefined, + "marginRight": undefined, + "marginStart": undefined, + "marginTop": undefined, + "marginVertical": undefined, + }, + { + "cursor": undefined, + }, + ] + } + underlayColor="#E4E7EA" + > + + + + + + + + + + + + + + diego.mello + + + + + +  + + + + + + 10:00 AM + + + + + + + + This message has error too + + + + + + + + + + + + +`; + +exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + + test.py + + + - - - - - document.pdf - - - - document.pdf - - - - - - - - - - - - image.png - - - - image.png - - - - + test.py + + + > + + + + Check out this Python file + + + + + - + - - - - - -`; - -exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - + - - diego.mello - - - - - - 10:00 AM - - - - - - - - test.py - - - - test.py - - - + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "500", + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + ] + } + > + Component.tsx + - - - - - - - Check out this Python file - - + Component.tsx + + + + + + TypeScript component + + + + + - + - - - - + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - + + config.json + + + + testID="markdown-preview-config.json" + > + config.json + - - + + - - - - diego.mello - - - - - 10:00 AM - - - - - - - - - - Component.tsx - - - - Component.tsx - - - - - - - - - - - - TypeScript component - - - - - - + Configuration file + + + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - - - - 10:00 AM - - + + main.go + + + - - - + This is the + + - config.json + + main + - + - config.json + entry point for the application - - + + - + + + + + + - - - - - Configuration file - - - - - - + File with description + + + - + - - - - + + + - - + + - - + - - - + + document.pdf + + + + testID="markdown-preview-document.pdf" + > + document.pdf + - - + + - - - - - diego.mello - - - - - - 10:00 AM - - - - - - - main.go - - - - - - - This is the - - - - - main - - - - - entry point for the application - - - - - - + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "500", + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + ] + } + > + image.png + - - - - - - - File with description - - + image.png @@ -57483,1172 +53022,1121 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna - + + - - - - + + + + +`; + +exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match snapshot 1`] = ` + + + - - + - - + + + + + + + + + + + + + + + + diego.mello + + + + + 10:00 AM + + - - - - - document.pdf - - - - document.pdf - - - - - - - + test.py + + + - - - - - image.png - - - - image.png - - - - + test.py + + + > + + + + Check out this Python file + + + + + - + - - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - - diego.mello - - - - - - 10:00 AM - - - - - - - - - - File.pdf - - - - - - - - - - - - - - - - - - File.pdf - - - - - - + Component.tsx + - - - - - - - multi file - - + Component.tsx + + + + + + TypeScript component + + + + + - + - - - - - -`; - -exports[`Story Snapshots: FullName should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - + + config.json + + + + testID="markdown-preview-config.json" + > + config.json + - - + + - - + - Diego Mello - - - 10:00 AM + Configuration file - - - - - - - - - - Message - - - - - - + + - + - - - - - -`; - -exports[`Story Snapshots: FullNameLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - Diego Mello - - - - - - 10:00 AM - - - - - Message - + main.go - - - - - - - - - - - - - - - - -`; - -exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = ` - - - + + + + + This is the + + + + + main + + + + + entry point for the application + + + + + + + + + + + + + + + File with description + + + + + + + + + + + + + + - - + + - + - - - - + + document.pdf + + + + testID="markdown-preview-document.pdf" + > + document.pdf + - - + + - - - - - diego.mello - - - 10:00 AM - - + }, + { + "borderColor": "#CBCED1", + }, + ] + } + testID="reply-undefined-undefined" + > - - - - + image.png + + + - - ... - - + }, + { + "color": "#2F343D", + "lineHeight": undefined, + }, + ] + } + testID="markdown-preview-image.png" + > + image.png @@ -60156,1422 +55950,1244 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = ` - + + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - 10:00 AM - - + }, + { + "borderColor": "#CBCED1", + }, + ] + } + testID="reply-undefined-File.pdf" + > - - - - + - Different user + + File.pdf + - + - - - - - - - - - - - - - - - - - - - - + - This is the third message + + File.pdf + - + + + > + + + + multi file + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: FullName should match snapshot 1`] = ` + + + - - + - - + - - - + + - - - - - - This is the second message - - - - - - + transition={null} + width={36} + /> - - - - + + + - - - - - - - - - - - - - - - - - - - - - + > + Diego Mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Message - - - - - - - - - - This is the first message - - - - - - + + - + - - - + + + `; -exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: FullNameLargeFont should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + Diego Mello + + + + 10:00 AM + + + - - + - diego.mello + Message - - - - - 10:00 AM + - - - - - - - ... - - - - - - - + - - - - + + + + +`; + +exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + ... - - - - - 10:00 AM + - - - - - - - Different user - - - - - - - + - - - - + + + - - + - - + - - - + + - - - - - - This is the third message - - - - - - + transition={null} + width={36} + /> - - - - + + + - - - - - - - - - - - + testID="username-header-Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + > + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + 10:00 AM + + + + + - - - - - - - This is the second message - - - - - - + Different user + + + - - + - - - - + + + - - + + - - + - - - - - - - - - - - - - - + - diego.mello + This is the third message - - - + + + + + + + + + + + + + + + + + + + + + + + + - 10:00 AM - - - - - - - - This is the first message - - - - - - + This is the second message + + + - + + - - - - - -`; - -exports[`Story Snapshots: Ignored should match snapshot 1`] = ` - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + - Message ignored. Tap to display it. + + + This is the first message + + - + - - - + + + `; -exports[`Story Snapshots: IgnoredLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = ` - - - + - + - - + - + + - - - - - - - - - + + + + + + + + + + + diego.mello + + + + + 10:00 AM + + + - Message ignored. Tap to display it. + + + ... + + - + - - - - - -`; - -exports[`Story Snapshots: InlineKatex should match snapshot 1`] = ` - - - + + + - - + - - + - + + + + + + + + + + + + - - - - - - - - - + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + Different user - - - - - - - - - - xˆ2 + yˆ2 - zˆ2 - - - - - - + + - + - - - - - -`; - -exports[`Story Snapshots: InlineKatexLargeFont should match snapshot 1`] = ` - - - + + + - - + + - - + - - - - - - - + This is the third message + + + - + + + + + + + + + + + + + + + + + + - - + - diego.mello + This is the second message - - - - - 10:00 AM + - - - - - - - xˆ2 + yˆ2 - zˆ2 - - - - - - - + + - - - - - -`; - -exports[`Story Snapshots: JitsiCall should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM + This is the first message - - - - + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: Ignored should match snapshot 1`] = ` + + + + + + + + + + + + + + - Call started by diego.mello - - - - - -  - - - Click to join! - - - - + transition={null} + width={20} + /> - + + + + + + + + + Message ignored. Tap to display it. + - + - - - + + + `; -exports[`Story Snapshots: JitsiCallLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: IgnoredLargeFont should match snapshot 1`] = ` - - - + - - + - - + + - - - - - - - + } + transition={null} + width={26} + /> - - + + + + + + + - - - - - diego.mello - - - - - - Call started by diego.mello - - - - - -  - - - Click to join! - - - - - - 10:00 AM - - - + Message ignored. Tap to display it. + - + - - - + + + `; -exports[`Story Snapshots: Katex should match snapshot 1`] = ` +exports[`Story Snapshots: InlineKatex should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - - diego.mello - - - 10:00 AM + + xˆ2 + yˆ2 - zˆ2 - - - - - - - - - - + + - + - - - + + + `; -exports[`Story Snapshots: KatexArray should match snapshot 1`] = ` +exports[`Story Snapshots: InlineKatexLargeFont should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - - diego.mello - - - 10:00 AM + + xˆ2 + yˆ2 - zˆ2 - - - - - - - - - - + + - + - - - + + + `; -exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: JitsiCall should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + Call started by diego.mello + + + - - - - diego.mello - - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - 10:00 AM + Click to join! - - - - - - - - + - - - + + + `; -exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: JitsiCallLargeFont should match snapshot 1`] = ` - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + Call started by diego.mello + + + - - - - diego.mello - - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - 10:00 AM + Click to join! - - - - - - - - + + 10:00 AM + + - - - + + + `; -exports[`Story Snapshots: Lists should match snapshot 1`] = ` +exports[`Story Snapshots: Katex should match snapshot 1`] = ` - - - + - - + - - + + - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - • - - - - - Dogs - - - - - - - • - - - - - cats - - - - - - - - - • - - - - - cats - - - - - - - - + transition={null} + width={36} + /> - - - + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - 1 - . - - - - - Dogs - - - - - - - 2 - . - - - - - Cats - - - - - - - - + - + - - - - + + + + +`; + +exports[`Story Snapshots: KatexArray should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - 1 - . - - - - - Dogs - - - - - - - - + - + - - - - + + + + +`; + +exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - + testID="username-header-diego.mello" + > + + diego.mello + + + + + 10:00 AM + + + - - - - - - - 2 - . - - - - - Cats - - - - - - - - + - - + - - - + + + `; -exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = ` - - - + - + + + + + + + + + + + + - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - + + + + + + + + + + + + +`; + +exports[`Story Snapshots: Lists should match snapshot 1`] = ` + + + + + + + + + + + + + + - - - diego.mello - - - - - - 10:00 AM - - - - - - - - • - - - - - Dogs - - - - - - - • - - - - - cats - - - - - - - - - • - - - - - cats - - - - - - - - + transition={null} + width={36} + /> - - - + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - + - diego.mello + • - - - - + + + Dogs + + + + + - 10:00 AM - - - - - - + + + - - 1 - . - - - - - Dogs - - - - - + + + + + + + + • + + + + - - 2 - . - - - - - Cats - - - - - - + cats + + + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - + - diego.mello + 1 + . + + + + + Dogs + + - - - 10:00 AM - - - - - - + + + - - 1 - . - - - - - Dogs - - - - - - + Cats + + + - + - - - - + + + - - + - - + - - - + + - - - - - - 2 - . - - - - - Cats - - - - - - - - + transition={null} + width={36} + /> - - - - + + + - - - - - - - -`; - -exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + 1 + . - 10:00 AM - - - - - - - - - this is a normal message - + Dogs - + - + - - - - + + + - - + + - - - - - - - - - - - - - - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + 2 + . - 10:00 AM - - - - -  - - - - - - - - - - Edited message - + Cats - + - + + - - - - + + + + +`; + +exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = ` + + + - - + - + + + + + + + + + + + + - - - - - - - - - - + > + diego.mello + - - - + + + 10:00 AM + + + + + + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + • - 10:00 AM - - - - -  - - - - - - - - - - Translated message - + Dogs - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + • - 10:00 AM + + + cats + + + + - - -  - - - - - - + • + - Encrypted message + + + cats + + - + - - - - + + + - - + - + - - - + - - - + + + + + + - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - 10:00 AM - - - @@ -74396,14 +68423,6 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -74415,477 +68434,123 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, - } - } - > - -  - - - - - - - - - - Error message - - + diego.mello + - - - - - - - - - - - - - - + + 10:00 AM + - - - - - - - - - - - - - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + 1 + . - 10:00 AM + + + Dogs + + -  + 2 + . - - - - - - - Message with read receipt - + Cats - + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + 1 + . - 10:00 AM - - - - - -  - - - -  - - - - - - - Encrypted message + } + } + > + + Dogs + + - + - - - - + + + - + - + - - - + + > + + + + 2 + . + + + + + Cats + + + + + + - - + + + + + + + + + +`; + +exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` + + + + + + - + - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - 10:00 AM - - + "backgroundColor": "#E4E7EA", + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "bottom": 0, + "left": 0, + "opacity": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + + + + + + + + + @@ -75887,14 +69792,6 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -75906,551 +69803,421 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername" > -  + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - -  + 10:00 AM + + + - -  - - - - -  - + { + "color": "#2F343D", + }, + ] + } + > + + + this is a normal message + + + + - -  - - - - - - - Encrypted message - - + - - + - - + - + - - - + - - - + + + + + + - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - 10:00 AM - - - @@ -76475,14 +70242,6 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -76494,551 +70253,457 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername" > -  + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - -  + 10:00 AM - -  - - - - -  - + [ + { + "lineHeight": 16, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - -  - - - - - Encrypted message - - - - - - - - - - - - - - - - - - - - + > + + + + Edited message + + + + - - + + + + + + + + + + - + - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - 10:00 AM - - + "backgroundColor": "#E4E7EA", + "borderBottomLeftRadius": undefined, + "borderBottomRightRadius": undefined, + "borderRadius": undefined, + "borderTopLeftRadius": undefined, + "borderTopRightRadius": undefined, + "bottom": 0, + "left": 0, + "opacity": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + + + + + + + + + @@ -77063,14 +70728,6 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -77082,2527 +70739,1863 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername" > -  + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - -  + 10:00 AM - -  - - - - -  - + [ + { + "lineHeight": 16, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - -  - - - - - Encrypted message - - - - - - - - - - - -`; - -exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + Translated message - - - - - 10:00 AM + - - - - - - - this is a normal message - - - - - - - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + 10:00 AM + - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - - -  - - - - + } + accessible={true} + collapsable={false} + focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "paddingLeft": 5, + } + } + > - 10:00 AM +  - - - - - - - Edited message - - - - - - - + + + + Encrypted message + + + + - - - - + + + - - + + + + + + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + 10:00 AM + + - - - + + + + + + + + - - - - - - - - - + > + Error message + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + 10:00 AM + + +  + + + + + - - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + Message with read receipt - - - - -  - - - - - - 10:00 AM + - - - - - - - Translated message - - - - - - - + - - - - + + + - - + - - + - - - - - - - - - - - - - - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - - -  - - - - - + + - 10:00 AM - - - - - Encrypted message - - - + transition={null} + width={36} + /> - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - @@ -79627,14 +72620,6 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -79646,1712 +72631,771 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername" > -  + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - - 10:00 AM - - - - - - - Error message - - + 10:00 AM - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - -  - - - - 10:00 AM +  - - - - - - - Message with read receipt - - - - - - + +  + + + + + + + Encrypted message + - + - - - - + + + - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - - -  - - - -  - - - - - 10:00 AM - - - - - Encrypted message - - - - - - - + "backgroundColor": "transparent", + "flexShrink": 1, + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "lineHeight": 22, + "textAlign": "left", + }, + { + "color": "#1F2329", + }, + ] + } + > + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + 10:00 AM + - - - - - - - - - - + - + +  + + + - - + + + - - - - - + "lineHeight": 16, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + +  + + + +  + - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - - -  - - - - -  - - - - -  - - - - -  - - - -  - - - - 10:00 AM + Encrypted message - - - - Encrypted message - - - - + - - + - - - - + - + + - - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + - - + + 10:00 AM + + + + - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - - - -  - - - - -  - - - + + + - -  - - - +  + + + + - -  - - - +  + + + + -  - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + +  + + + + + + - 10:00 AM + Encrypted message - - - - Encrypted message - - - - + - - + - - - - + - + + - - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + - - + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + 10:00 AM + + + + + - + + + +  + + + + +  + + + + +  + + + +  + + + + + + + Encrypted message + + + + + + + + + +`; + +exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` + + + + + + + + + + + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - + + + + + + + + + + @@ -82294,14 +74869,6 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` accessible={true} collapsable={false} focusable={true} - hitSlop={ - { - "bottom": 4, - "left": 4, - "right": 4, - "top": 4, - } - } onBlur={[Function]} onClick={[Function]} onFocus={[Function]} @@ -82313,7429 +74880,8487 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "paddingLeft": 5, + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, } } + testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername" > -  + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - -  - - - + + + 10:00 AM + + + - + -  - - - - -  - + ] + } + > + + + this is a normal message + + + + - -  - - + + + + + + + + + + + + - 10:00 AM - - - + - - Encrypted message - - + + + + + + - - - - - - - -`; - -exports[`Story Snapshots: Mentions should match snapshot 1`] = ` - - - - - - - - - - + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + + + - - - - - - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + Edited message - - - - - - - - - - rocket.cat - - - - - - diego.mello - - - - - - all - - - - - - here - - - - - - general - - - - - - team - - - - - - + + - + - - - - + + + - - + + + + + + + + + + + + + - - - + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + + + - - - - - - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + Translated message - - - - - - - - - - rocket.cat - - - Lorem ipsum dolor - - - diego.mello - - - sit amet, - - - all - - - consectetur adipiscing - - - here - - - elit, sed do eiusmod tempor - - - general - - - incididunt ut labore et dolore magna aliqua. - - - - - - + + - + - - - - - -`; - -exports[`Story Snapshots: MentionsLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + + - - - - - - - - - - - - - - - diego.mello - - - - - 10:00 AM +  - - - - - - - rocket.cat - - - - - - diego.mello - - - - - - all - - - - - - here - - - - - - general - - - - - - team - - - - - - - + + 10:00 AM + + + + + Encrypted message + + + + - - - - + + + - - - - - - + + - + + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + + + +  + + + + + + 10:00 AM + + + + + + - - + Error message + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + +  + + + + + 10:00 AM + + + - - - diego.mello + Message with read receipt - - - - - 10:00 AM + - - - - - - - rocket.cat - - - Lorem ipsum dolor - - - diego.mello - - - sit amet, - - - all - - - consectetur adipiscing - - - here - - - elit, sed do eiusmod tempor - - - general - - - incididunt ut labore et dolore magna aliqua. - - - - - - - + - - - - - -`; - -exports[`Story Snapshots: Message should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + - - + +  + + + - - diego.mello - - - 10:00 AM - - - - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="read-receipt-read" + > +  + + + + + 10:00 AM + + + + + Encrypted message + - + - - - - - -`; - -exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + - - - + + + - - - - - - - - - - - - - +  + + + + - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Forwarded message with file inside - - - - - - - - - - - - - rocket.cat - - - 10:00 AM - - - - - - - - - - user - - - document.pdf - - - - document.pdf - - - - - - - - - - - - - - - - + "lineHeight": 20.8, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + +  + + + +  + + + + + 10:00 AM + + + + + Encrypted message + - - - - + + + - - - - - - + + - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Forwarded message with nested image - - - - - - - - - - - - - rocket.cat - - - - - - - - - Nested image from forwarded message - - - - - - - - - - -  - - - - - - - - - - - - - - - - - - - + transition={null} + width={46.800000000000004} + /> + + + - - - - - - + - - + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + + + +  + + + + +  + + + + +  + + + + +  + + + +  + + + + + 10:00 AM + + + + + Encrypted message + + + + + + + + + + + + + + + + + + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + + + +  + + + + +  + + + + +  + + + + +  + + + +  + + + + + 10:00 AM + + + + + Encrypted message + + + + + + + + + +`; + +exports[`Story Snapshots: Mentions should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + + + + + + + rocket.cat + + + + + + diego.mello + + + + + + all + + + + + + here + + + + + + general + + + + + + team + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + + + + + + + rocket.cat + + + Lorem ipsum dolor + + + diego.mello + + + sit amet, + + + all + + + consectetur adipiscing + + + here + + + elit, sed do eiusmod tempor + + + general + + + incididunt ut labore et dolore magna aliqua. + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: MentionsLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + + + 10:00 AM + + + + + + + + rocket.cat + + + + + + diego.mello + + + + + + all + + + + + + here + + + + + + general + + + + + + team + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + + rocket.cat + + + Lorem ipsum dolor + + + diego.mello + + + sit amet, + + + all + + + consectetur adipiscing + + + > + here + - diego.mello + elit, sed do eiusmod tempor - 10:00 AM + general - - - - - - - - - - Forwarded message with nested audio - - - - - - + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: Message should match snapshot 1`] = ` + + + + + + + + + + + + + + - - - - - - - rocket.cat - - - - - - - -  - - - - - - - - - - - - - - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + - + + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`] = ` + + + - - + - - + - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Forwarded message with nested video - - - - - - - - - - - - - rocket.cat - - - - - - - - -  - - - - - - - - - - - + "backgroundColor": undefined, + "borderRadius": undefined, + "margin": undefined, + "marginBottom": undefined, + "marginEnd": undefined, + "marginHorizontal": undefined, + "marginLeft": undefined, + "marginRight": undefined, + "marginStart": undefined, + "marginTop": undefined, + "marginVertical": undefined, + }, + { + "cursor": undefined, + }, + ] + } + underlayColor="#E4E7EA" + > + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Forwarded message with file inside - - - + + + + + + - - - - - - Message forwarded twice with file inside - - - - - + + rocket.cat + + + 10:00 AM + + + - - - rocket.cat - - - 10:00 AM - - + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "alignSelf": "flex-start", + "borderLeftWidth": 2, + "flex": 1, + "flexDirection": "row", + }, + { + "borderColor": "#CBCED1", + }, + ] + } + testID="reply-user-undefined" + > + + user + + + document.pdf + + + - - - - - rocket.cat - - - 10:00 AM - - - - - - - - - - user - - - document.pdf - - - - document.pdf - - - - - - - - - + document.pdf + @@ -89747,3048 +83372,3615 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1` - + - - - - - -`; - -exports[`Story Snapshots: MessageWithReadReceipt should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - - diego.mello - - - 10:00 AM - - - + -  + Forwarded message with nested image - - - - - - - - - I'm fine! - - - - - - + + - - - - - - - - - - - - - - - - - - + rocket.cat + + + + - - I'm fine! - - - + + + + Nested image from forwarded message + + + + + + + + + + +  + + + + + + + - - -  - - - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM - - - - -  + Forwarded message with nested audio - - + + + + + + - + rocket.cat + + + + - - I'm fine! - - - + + +  + + + + + + + + + + + + + + - + - - - - + + + - - + - - + - - - + + - - - - - - I'm fine! - - - - - - + transition={null} + width={36} + /> - - - -  - - - - + + + - - - - - - - -`; - -exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - - diego.mello - - - - -  - - - + > - 10:00 AM + + + Forwarded message with nested video + + + + + + - + rocket.cat + + + + - - I'm fine! - - - + + + +  + + + + + - + - - - - + + + - - + - - + - - - + + - - - - - - I'm fine! - - - - - - + transition={null} + width={36} + /> - - - -  - - - - + + + - - - - - - - - - - - - + diego.mello + + - - - - - - - - - + > + 10:00 AM + + + + - - + - diego.mello + Message forwarded twice with file inside - - + + + + + + - -  - - - - - 10:00 AM - - - + rocket.cat + + + 10:00 AM + + + + - - I'm fine! - - - + + + + + rocket.cat + + + 10:00 AM + + + + + + + + + + user + + + document.pdf + + + + document.pdf + + + + + + + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithReadReceipt should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - + - - - - - - I'm fine! - - - - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "lineHeight": 18, + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + ] + } + > + 10:00 AM + -  +  - + + + + + + + I'm fine! + + + + + + + - - - - - -`; - -exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = ` - - - + + + - - + + - - - - - - - - - - - - - - - + - diego.mello - - - 10:00 AM + I'm fine! - - - - - - - - - - - - I'm a very long long title and I'll break - - - - - - - How are you? - - - - - - - - - - - - - - - I'm fine! - - - - - - + + - + + + + +  + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + "backgroundColor": "transparent", + "flexShrink": 1, + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "lineHeight": 22, + "textAlign": "left", + }, + { + "color": "#1F2329", + }, + ] + } + > + diego.mello + + + 10:00 AM + - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - rocket.cat - - - - - - - How are you? - - - - - - - - - - - - - - - - - - rocket.cat - - - - - - - How are you? - - - - - - - - - - - - - - - Reply attachment can have a description - - - - - - - +  + + + + + + + + - - - - - I'm fine! - - - - - - + I'm fine! + + + - + - - - - + + + - - + + - - + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + I'm fine! + + + + + + + + + + +  + + + + + + + + + +`; + +exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + +  + + + + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + I'm fine! - - - - - - - - - - Looks cool! - - - - - - - - - - - - - rocket.cat - - - - - - - - - What you think about this one? - - - - - - - - - - -  - - - - - - - - - - - - - + + - + - - - - + + + - - + + - - - - - - - - - - - - - - - + - diego.mello - - - 10:00 AM + I'm fine! - - - - - - - - - - Yes, I am - - - - - - - - - - - - - rocket.cat - - - - - - - - - Are you seeing this mario - - - - - - ? - - - - - - - - - - -  - - - - - - - - - - - - - + + - + + + + +  + - - - - - -`; - -exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + +  + + + + + 10:00 AM + + + - - + - diego.mello - - - 10:00 AM + I'm fine! - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - rocket.cat - - - script.sh - - - - script.sh - - - - - - - - - - - - Here is the file - - - - - - + I'm fine! + + + - + + + + +  + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - + + I'm a very long long title and I'll break + + + - - - - - rocket.cat - - - config.yaml - - - - - - - This is a configuration file with - - - - - important - - - - - settings - - - - - - + How are you? + + + - + + + + + + - - - - - Got it! - - - - - - + I'm fine! + + + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - + + rocket.cat + + + - - - - - rocket.cat - - - index.ts - - - index.ts + How are you? - - + + + + + + + + + + + + + rocket.cat + + + - - - - - rocket.cat - - - styles.css - - - styles.css + How are you? - - + + + + + - + + - - - - Multiple files - - + Reply attachment can have a description - - + + + + + + + + I'm fine! + + + + + - + - - - - - -`; - -exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello + Looks cool! - - + + + + + + - - - 10:00 AM - - + + rocket.cat + + + + + + + What you think about this one? + + + + + - - rocket.cat - - + - script.sh - - - + + +  + + + - script.sh - + "bottom": 8, + "flexDirection": "row", + "gap": 8, + "position": "absolute", + "right": 8, + } + } + /> + - - - - - - Here is the file - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello + Yes, I am - - + + + + + + - - - 10:00 AM - - + + rocket.cat + + + + + + + Are you seeing this mario + + + + + + ? + + + + + - - rocket.cat - - + - config.yaml - - - - + - - This is a configuration file with - - - - - important - - - - - settings - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  - + + - - - - - - Got it! - - - - - - + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - - - - 10:00 AM - - - - + - - - rocket.cat - - - index.ts - - - - index.ts - - - - - - - - - - - - rocket.cat - - - styles.css - - - - styles.css - - - + script.sh + - - - - - - - Multiple files - - + script.sh + + + + + + Here is the file + + + + + - + - - - - + + + - - + + + + + + + + + + + + + - + - + diego.mello + + - - - - - - - - - + > + 10:00 AM + + + + - - - diego.mello - - - - - - 10:00 AM - - - - - - - - Forwarded message with nested audio - - - - - + + rocket.cat + + + config.yaml + + + - - - - - rocket.cat - - - + - - - - -  - - - - - - - - - - - - - - - - + important + + + + + settings + + + + + + + + + Got it! + + + + + - + - - - - + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - + + rocket.cat + + + index.ts + + + + testID="markdown-preview-index.ts" + > + index.ts + - - + + - - - - - diego.mello - - - - - - 10:00 AM - - + + + rocket.cat + + + styles.css + + - - - Forwarded message with nested video - - + styles.css - + + + + + + - - - - - - - rocket.cat - - - - - - - - -  - - - - - - - - - - - + "color": "#2F343D", + }, + ] + } + > + Multiple files + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot 1`] = ` + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={46.800000000000004} + /> + + + + + + + + + + diego.mello + + + + 10:00 AM + + + - - - diego.mello - - - - - - 10:00 AM - - + + + rocket.cat + + + script.sh + + - - - Message forwarded twice with file inside - - + script.sh - + + + + + + - - - - - - - rocket.cat - - - 10:00 AM - - - - - - - - - - rocket.cat - - - 10:00 AM - - - - - - - - - - user - - - document.pdf - - - - document.pdf - - - - - - - - - - - - - - - - - + "color": "#2F343D", + }, + ] + } + > + Here is the file + + + - - - - - - - - - - -`; - -exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = ` - - - + + + + + + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - + - - diego.mello - + + + rocket.cat + + + config.yaml + + + + + + + This is a configuration file with + + + + + important + + + + + settings + + + + + - + + + + - 10:00 AM - - - - - - - - - - I'm a very long long title and I'll break - - - - - - - How are you? - - - - - - - - - - - - - - - I'm fine! - - - - - - + Got it! + + + - + - - - - + + + - - + + + + + + + + + + + + + - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - + + rocket.cat + + + index.ts + + + + testID="markdown-preview-index.ts" + > + index.ts + - - + + - - - - - diego.mello - - - - - - 10:00 AM - - - - + - - - rocket.cat - - - - - - - How are you? - - - - - - - - - + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "500", + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + ] + } + > + styles.css + - - - - - - - I'm fine! - - + styles.css - - - + + + + + + + Multiple files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - diego.mello + Forwarded message with nested audio - - + + + + + + - - - 10:00 AM - - - - - - - - Looks cool! - - - - - + + rocket.cat + + + - rocket.cat +  + - - - - What you think about this one? - - - + /> - - - - - -  - - - - - + "borderRadius": 6, + "height": 12, + "position": "absolute", + "width": 12, + }, + { + "backgroundColor": "#D1EBFE", + }, + [Function], + ] + } + /> @@ -105239,886 +97879,702 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - - diego.mello - - - - - 10:00 AM - - - - - - - - Yes, I am - - - - - + [ + { + "flexShrink": 1, + "fontSize": 16, + "lineHeight": 22, + }, + { + "color": "#2F343D", + }, + ] + } + > + Forwarded message with nested video + + + + + + + + + + rocket.cat + + + + - rocket.cat +  - - - - - - - Are you seeing this mario - - - - - - ? - - - - - - - - - - -  - - - - - - - @@ -106128,841 +98584,901 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = - + - - - - - -`; - -exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - + + + diego.mello + + + + + + 10:00 AM + + - + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + Message forwarded twice with file inside + + + - - - diego.mello - - - 10:00 AM - - - - - - - - - - - How are you? - - - - - - - View thread - - - - - -  - - - 1 - - - + - -  - - - 0 - - + 10:00 AM + - - - - - + + -  - + + + rocket.cat + + + 10:00 AM + + + + + + + + + + user + + + document.pdf + + + + document.pdf + + + + + + + - - + + @@ -106970,872 +99486,595 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` - + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] = ` + + + - - + - - -  - - - + - How are you? - - - -  - - - - - - - - - - - - - - - - - - + /> - + - I'm fine! - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - + + - - - - - - - - - - - + + diego.mello + + + -  - + /> - Thread with emoji :) 😂 + 10:00 AM - -  - - - - - - + - - - + + > + + I'm a very long long title and I'll break + + + + + + + How are you? + + + + + - - + + - - - I'm fine! + + + I'm fine! + + - + - - - - + + + - - + - - -  - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - -  - - - - - - - - - - - - - - - - - + - - + + - I'm fine! - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - + + - - - - - - - - - - - + + diego.mello + + + -  - + /> - How are you? + 10:00 AM - -  - - - - - - + - - - + + + rocket.cat + + + + + + + How are you? + + + + + + + + + + + + + + + + + - - - + > + I'm fine! + + + + - + + + + + + + + + + + + + + - - + + - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - + + - - - - - - - - - - - + + diego.mello + + + -  - + /> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + 10:00 AM - -  - - - - - + + + + + Looks cool! + + + + + - + - - - + + > + + rocket.cat + + + + + + + + + What you think about this one? + + + + + + + + + + +  + + + + + + + + - - - - - - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - + - + - - - - + + + - - + - - -  - - - + - Thread with attachment - - - -  - - - - - - - - - - - - - - - - - - + /> - + - This is a description :nyan_rocket: - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - + + - - - - - - - - - - - + + diego.mello + + + -  - + /> - Thread with image attachment - - - -  - - - - + "color": "#6C727A", + }, + ] + } + > + 10:00 AM + - + - - - - - - - + > + Yes, I am + + + + - - - - example.png - + + + + + rocket.cat + + + + + + + + + Are you seeing this mario + + + + + + ? + + + + + + + + + + +  + + + + + + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = ` + + + - - + - - -  - - - - Thread with file attachment - - + - -  - - + + + + + + + + - + - + + 10:00 AM + + + + + + + + - - - - - - + > + How are you? + + + + - - - presentation.pptx + View thread + + + + +  + + + 1 + + + + +  + + + 0 + + + + + + + + + + +  + + + + + + - + - - - - + + + - - + + +  + + + - + - + - -  - - - - Thread with multiple attachments - - - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - first-file.pdf - - - - + I'm fine! + + - - - - - -`; - -exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = ` - - - + + + - - + - - - - - - - - - - - - - - - +  + + + + Thread with emoji :) 😂 + + + +  + + + + + + + + - - - - diego.mello - - - - - + + - 10:00 AM - - - - - - - - How are you? - - - - - - - - - View thread - - - - - - -  - - - 1 - - - - -  - - - 0 - - - - - - - - - - -  - - - - - - - - + transition={null} + width={20} + /> - + + + + + + + + + I'm fine! + - + - - - - + + + - - + + +  + + + - + - + - -  - - - - How are you? - - - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - I'm fine! - - - - + I'm fine! + + - - - - + + + - - + + +  + + + - + - - -  - - - + - Thread with emoji :) 😂 - - - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - I'm fine! - - - - + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - - - - + + + - - + + +  + + + - + - + - -  - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + - + - - + - - - - - - - - - - - + /> - + - I'm fine! - + style={ + { + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> - - + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - - - - + + + - - + + +  + + + - + + - + + + + + + - -  - - - + + + + + + + + + - How are you? - + } + > -  + This is a description :nyan_rocket: - + + + + + + + + + + + + - + + + Thread with image attachment + + + - - - - - - - - - - - - +  + + + + + + + - - + + - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - + style={ + { + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> - - + + - - - - - - - - - - -  + example.png - + + + + + + + + + + + + + +  + + + + Thread with file attachment + + + - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - + presentation.pptx + + - - - - + + + - - + + - + +  + + + - + + - + + + + + + - -  - - - + + + + + + + + + - Thread with attachment - + } + > -  + first-file.pdf + + + + + + + + +`; + +exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] = ` + + + + + + + + - + - - + - - - - - - - - - - - + /> - + - This is a description :nyan_rocket: - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - + + - - - - - - - - - - - + + diego.mello + + + -  - + /> - Thread with image attachment + 10:00 AM - -  - - - - - - + - - - - - - - + > + How are you? + + + + - - - example.png + View thread - - - - - - - - - - - - - - - - - - -  - - - - Thread with file attachment - - - -  - - - - - - - - - - +  + + + 1 + + + + +  + + + 0 + + + + + + + + - - - - - - - - - - presentation.pptx - + /> + + +  + + + + + - + - - - - + + + - - + + +  + + + - + + - + + + + + + - -  - - - + + + + + + + + + - Thread with multiple attachments - + } + > -  + I'm fine! - + + + + + + + + + + + + - - - - - - - - - - - - - - + + + Thread with emoji :) 😂 + + + - - - first-file.pdf - - - - - + "lineHeight": 26, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - - - - - - -`; - -exports[`Story Snapshots: Names should match snapshot 1`] = ` - - - - - - - - + - - - - - - - - - - - - - - - - - JD - - - 10:00 AM - - - + + - - - - - - - - Small name (2 chars) - - - - - - + transition={null} + width={26} + /> - + + + + + + + + + I'm fine! + - + - - - - + + + - + + +  + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + +  + + + + - + - + - - - + - - - + + + + + + - - - JD - - - 10:00 AM + I'm fine! - + + + + + + + + + + + + + - -  - - - -  - - - - -  - - - - -  - - - +  + + + + How are you? + + + - -  - - - +  + + + + + + + + -  - - + + + + + + + + - Encrypted message + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - + - - + - - + - - + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + +  + + + + + + + - - - - - - - - - - - - - - - - - John Doe - - - 10:00 AM - - - + + - - - - - - - - Medium name (8 chars) - - - - - - + transition={null} + width={26} + /> - + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + - + - - - - + + + - + +  + + + - + Thread with attachment + + + - + + + + + + - - - + - - - + + + + + + - - - John Doe - - - 10:00 AM + This is a description :nyan_rocket: - + + + + + + + + + + + + + - -  - - - -  - - - - -  - - - - -  - - - - -  - - - +  + + + + Thread with image attachment + + + +  + + + + + + + + -  - - + + + + + + + + - Encrypted message + example.png - + - - + - - + - - - - - - - - - - - - - - - - - - - - John Doe With An Extremely Long Display Name That Should Definitely Be Truncated In Any Reasonable User Interface Layout - - - 10:00 AM - - - - - - - - - - - Long name (22 chars) - should truncate - - - - - - - - - - + "lineHeight": 26, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - - - - - - - - - + + Thread with file attachment + + + +  + + + - + - + - - - + - - - + + + + + + - - - John Doe With An Extremely Long Display Name That Should Definitely Be Truncated In Any Reasonable User Interface Layout - - - 10:00 AM + presentation.pptx - + + + + + + + + + + + + + +  + + + + Thread with multiple attachments + + + +  + + + + + + + - -  - - - -  - - - - -  - - - - -  - - - - + + -  - - - -  - - + "headers": { + "User-Agent": "RC Mobile; ios unknown; vunknown (unknown)", + }, + "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=52&rc_token=abc&rc_uid=y8bd77ptZswPj3EW8", + }, + ] + } + style={ + { + "borderRadius": 4, + "height": 26, + "width": 26, + } + } + transition={null} + width={26} + /> + + + + + - Encrypted message + first-file.pdf - + - + `; -exports[`Story Snapshots: Pinned should match snapshot 1`] = ` +exports[`Story Snapshots: Names should match snapshot 1`] = ` - - - + - - + - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - + + - -  - - - - - - - - - - Message header - - - - - - + transition={null} + width={36} + /> - - - + + + - - - - - - - - - - - - - - - - - - - - Message without header - - - - - - - - - -  + JD + + + 10:00 AM + + + + + + + + + Small name (2 chars) + + + + + - + - - - - - -`; - -exports[`Story Snapshots: PinnedLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + - - + + 10:00 AM + + + + +  + + + - + + + - - - - - - - - - - - - +  + + + + +  + + + + +  + + + - - - - - diego.mello - - - - -  - - - - - 10:00 AM - - - - - - - - Message header - - - - - - - - - - + "marginTop": -5, + "paddingLeft": 5, + }, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + testID="read-receipt-read" + > +  + + + + + + + Encrypted message + - - - - + + + - - + - - + - - - + + - - - - - - Message without header - - - - - - + transition={null} + width={36} + /> - - - -  - - - - + + + - - - - - - - -`; - -exports[`Story Snapshots: Reactions should match snapshot 1`] = ` - - - - - - - - + - - - - - - - - - - - + > + John Doe + + + 10:00 AM + + + + - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Reactions - - - - - - - - - - 😂 - - - 1 - - - - - - - - 99 - - - - - - - 🤔 - - - 999 - - - - + + + - - - 🤔 - - - 9999 - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + John Doe + + + 10:00 AM + + + + +  + + + +  + + + + +  + + + + +  + + + + +  + + + +  + + + + + + + Encrypted message + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + John Doe With An Extremely Long Display Name That Should Definitely Be Truncated In Any Reasonable User Interface Layout + + + 10:00 AM + + + + + + + + + + - - -  - - - - - + Long name (22 chars) - should truncate + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + John Doe With An Extremely Long Display Name That Should Definitely Be Truncated In Any Reasonable User Interface Layout + + + 10:00 AM + + + + +  + + + +  + + + + +  + + + + +  + + + + +  + + + +  + + + + + + + Encrypted message + - - - - + + + + +`; + +exports[`Story Snapshots: Pinned should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Multiple Reactions - - - - - - +  + + + + + + + + - - - - - 1 - - - - - - - - 1 - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - 1 - - - - + + + + + + + + + +  + + + + + + + + + +`; + +exports[`Story Snapshots: PinnedLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + +  + + + + + 10:00 AM + + + + + + + - - - ❤️ - - - 1 - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - 🐶 - - - 1 - - - - + + + + + + + + + +  + + + + + + + + + +`; + +exports[`Story Snapshots: Reactions should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + + + + + + - - - 😀 - - - 1 - - - - + + + + + + + + + 😂 + + - - - 😬 - - - 1 - - - - + 1 + + + + + + + - - - 😁 - - - 1 - - - - + 99 + + + + + + + 🤔 + + + 999 + + + + + + + 🤔 + + + 9999 + + + + + + - - -  - - - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - + - - - - - -`; - -exports[`Story Snapshots: ReactionsLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - - diego.mello - - - - - 10:00 AM - - - - - - - - Reactions - - - - - - - - - - 😂 - - - 1 - - - - - - - - 99 - - - - - - - 🤔 - - - 999 - - - - + - - - 🤔 - - - 9999 - - - - + + + + + + + + + + 1 + + + + + + + + 1 + + + + + + - - -  - - - - + } + height={19} + nativeViewRef={{"current":"NativeComponent"}} + onError={[Function]} + onLoad={[Function]} + onLoadStart={[Function]} + onProgress={[Function]} + placeholder={[]} + source={ + [ + { + "uri": "https://open.rocket.chat/emoji-custom/nyan_rocket.png", + }, + ] + } + style={ + { + "height": 19, + "width": 19, + } + } + transition={null} + width={19} + /> + + 1 + - - - - - - - - - - - - - - - - - - + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + hitSlop={ + { + "bottom": 4, + "left": 4, + "right": 4, + "top": 4, + } + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "borderRadius": 4, + }, + { + "backgroundColor": "#E4E7EA", + }, + ] + } + testID="message-reaction-:heart:" + > + + + ❤️ + + + 1 + + + - + - + 🐶 + + - - - - - - + 1 + + - - - - - diego.mello - - - - - - 10:00 AM - - - - - - - - Multiple Reactions - - - - - - - - - - - 1 - - - - + - - - - 1 - - - - + 1 + + + + + + + 😬 + + + 1 + + + + + + - - - - 1 - - - - + 😁 + + + 1 + + + + + + - - - ❤️ - - - 1 - - - - +  + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: ReactionsLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + + + 10:00 AM + + + + + + + - - - 🐶 - - - 1 - - - - + + + + + + + + + 😂 + + - - - 😀 - - - 1 - - - - + 1 + + + + + + + - - - 😬 - - - 1 - - - - + 99 + + + + + + + 🤔 + + + 999 + + + + + + + 🤔 + + + 9999 + + + + + + - - - 😁 - - - 1 - - - - +  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + + + 10:00 AM + + + + + + + - - -  - - - - - + Multiple Reactions + + + - - - - - - - - - - -`; - -exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should match snapshot 1`] = ` - - - - - - - - - - - + - + - - - - - - + 1 + + - - - + + 1 + + + + + + - - diego.mello - - - 10:00 AM - - - + + 1 + + + - - - - - - How are you? - - - - - - - + - - View thread - - - - - - -  - - - 1 - - - - -  - - - 0 - - - - - - - - - - -  - - - - - - - + "color": "#1D74F5", + }, + ] + } + > + 1 + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - + 1 + + + + - - - I'm fine! - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - + "color": "#1D74F5", + }, + ] + } + > + 1 + + + - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - + + 😬 + + + 1 + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - + 1 + + + + - - - This is a description - + +  + + - + - - - + + + `; -exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello + How are you? - - - + + + + + + - 10:00 AM + View thread + + - - - - - How are you? - - - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + 1 + - +  + + - - View thread - - - + + + + + + - - -  - - - 1 - - - - -  - - - 0 - - - + /> - - - - - - -  - - - - +  + - - + + - + - - - - + + + - - + + + + + + + + + + + + + + + I'm fine! + + + + + + + + + + + + + + + + + - - + - + + - - - - - - - - - + + + + + + + + - - - - I'm fine! - - - - + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - + This is a description + + - - - - + + + + +`; + +exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont should match snapshot 1`] = ` + + + - - + - + - - - - - - - - - - - - - - + + - This is a description - + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - + + - - - - - - - -`; - -exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReply should match snapshot 1`] = ` - - - - - - - - + + diego.mello + + + -  - + /> - How are you? + 10:00 AM - -  - - - - - + + + + + How are you? + + + + + - + - + View thread + + + + + > + +  + + + 1 + + - +  + + + 0 + + + + + + + + - - - - - - - - - - I'm fine! - + /> + + +  + + + + + - + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - Cool! - - - - + I'm fine! + + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - + [ + { + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "lineHeight": 22, + "textAlign": "left", + }, + { + "color": "#2F343D", + "lineHeight": undefined, + }, + ] + } + testID="message-preview-Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - This is a description - - - - + This is a description + + - - - + + + `; -exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReplyLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReply should match snapshot 1`] = ` - - - + + +  + + + - + - + - -  - - - - How are you? - - - -  - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + - + - - + - - - - - - - - - - - + /> - + - I'm fine! - + style={ + { + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> - - + + + + + + + + + I'm fine! + + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - Cool! - - - - + Cool! + + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + - - - - + + + - - + - + - - + - + + - - - - - - - - - + + + + + + + + - - - - This is a description - - - - + This is a description + + - - - + + + `; -exports[`Story Snapshots: ShowButtonAsAttachment should match snapshot 1`] = ` +exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadReplyLargeFont should match snapshot 1`] = ` - - - + - - + + + How are you? + + + +  + + + + + + + - - + + - - - - - - - + } + transition={null} + width={26} + /> - - + + + + + + + + I'm fine! + + + + + + + + + + + + + + + + + + - - - - diego.mello - - - 10:00 AM - - - + + - - - - - - - - Test Button - - - - - - - - Text button - - - - + transition={null} + width={26} + /> - + + + + + + + + + Cool! + - + - - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={26} + /> - - + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + + + + + + + + + + + + + + - - - - diego.mello - - - 10:00 AM - - - + + - - - - - - - - 🥑 - - - - - - - - Message with markdown - - - - - - - - - - - - Some text - - - - - - - - - - This is a test - - - - - - - - Text button - - - - + transition={null} + width={26} + /> - + + + + + + + + + This is a description + - + - - - + + + `; -exports[`Story Snapshots: ShowButtonAsAttachmentLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: ShowButtonAsAttachment should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + Test Button + + + + + + + + Text button + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - + + 🥑 + + - diego.mello + - - + + + Message with markdown + + + + + + - + } + /> - 10:00 AM - - - - - - - Test Button - - - - - - - - Text button - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + { + "color": "#2F343D", + }, + { + "fontStyle": "italic", + }, + ] + } + > + Some text + + + + + - + + - diego.mello + This is a test - - + + + + - + } + /> - 10:00 AM + Text button - + + + + + + + + + + + +`; + +exports[`Story Snapshots: ShowButtonAsAttachmentLargeFont should match snapshot 1`] = ` + + + + + + + + + + + + + + - - - - - - 🥑 - - - - - - - - Message with markdown - - - - - - - - - - - - Some text - - - - - - - - - - This is a test - - - - - + + + + + + + + + + + diego.mello + + + + + + 10:00 AM + + + + + + + - - - Text button - - - - + Test Button + + + + + + + Text button + + - + - - - - - -`; - -exports[`Story Snapshots: StaticAvatar should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + + + 🥑 + + - diego.mello + + - 10:00 AM + } + } + > + + Message with markdown + + - - - + + + - - - - Message - + Some text - - - + + + + + + + + This is a test + + + + + + + Text button + + - + - - - + + + `; -exports[`Story Snapshots: StaticAvatarLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: StaticAvatar should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - + diego.mello + + - - - - - - - - - + > + 10:00 AM + + + + - - + - diego.mello + Message - - - - - 10:00 AM + - - - - - - - Message - - - - - - - + - - - + + + `; -exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` +exports[`Story Snapshots: StaticAvatarLargeFont should match snapshot 1`] = ` - - - - + - - + + - - - - - - - + + + + + + + + - - + - diego.mello - - - + + + diego.mello + + + + + + 10:00 AM + + - message removed - - + "gap": 4, + } + } + > + + + + + + Message + + + + + + + + - + - - + + +`; + +exports[`Story Snapshots: SystemMessages should match snapshot 1`] = ` + + + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - joined the channel - + > + diego.mello - - + + + message removed + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - Pinned a message: - + > + diego.mello - - - + + + joined the channel + + + - - + - - - - - - + + - - - - - - - - - - diego.mello - - - - left the channel - + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> + + + + + + + + + + diego.mello - - + + + Pinned a message: + + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - removed rocket.cat - + > + diego.mello - - + + + left the channel + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - added rocket.cat - + > + diego.mello - - + + + removed rocket.cat + + + - - + - - - - - - - - - - - - - - - - diego.mello - - - + + - muted rocket.cat - + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> + + + + + + + + + + diego.mello - - + + + added rocket.cat + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - unmuted rocket.cat - + > + diego.mello - - + + + muted rocket.cat + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - defined rocket.cat as admin - + > + diego.mello - - + + + unmuted rocket.cat + + + - - + - - - - + + - - - - - - + + + - + + - + + diego.mello + + + - - - diego.mello - - - - removed rocket.cat as admin - + > + defined rocket.cat as admin - - + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room name to: New name - + > + diego.mello - - + + + removed rocket.cat as admin + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room description to: new description - + > + diego.mello - - + + + changed room name to: New name + + + - - + - - - - - - + + - - - - - - - - - - diego.mello - - - - changed room announcement to: new announcement - + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> + + + + + + + + + + diego.mello - - + + + changed room description to: new description + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room topic to: new topic - + > + diego.mello - - + + + changed room announcement to: new announcement + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room to public - + > + diego.mello - - + + + changed room topic to: new topic + + + - - + - - - - - - - - - - - - - - - - diego.mello - - - + + + - disabled E2E encryption for this room - + "borderRadius": 4, + "height": 20, + "width": 20, + } + } + transition={null} + width={20} + /> + + + + + + + + + + diego.mello - - + + + changed room to public + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - enabled E2E encryption for this room - + > + diego.mello - - + + + disabled E2E encryption for this room + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - removed @rocket.cat from this team - + > + diego.mello - - + + + enabled E2E encryption for this room + + + - - + - - - - + + - - - - - - + + + - + + - + + diego.mello + + + - - - diego.mello - - - - added @rocket.cat to this team - + > + removed @rocket.cat from this team - - + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - added #channel-name to this team - + > + diego.mello - - + + + added @rocket.cat to this team + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - converted #channel-name to a team - + > + diego.mello - - + + + added #channel-name to this team + + + - - + - - - - + + - - - - - - + + + - + + - + + diego.mello + + + - - - diego.mello - - - - converted #channel-name to channel - + > + converted #channel-name to a team - - + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - deleted #channel-name - + > + diego.mello - - + + + converted #channel-name to channel + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - removed #channel-name from this team - + > + diego.mello - - + + + deleted #channel-name + + + - - + - - - - + + - - - - - - + + + - + + - - - - diego.mello - - - - was removed by ABAC - + + + diego.mello - - + + + removed #channel-name from this team + + + - - + - - - - + + - - - - - - + + + - + + - + + > + diego.mello + + - Unsupported system message + was removed by ABAC - - + + + + + + + + + + + + + + + + + + + + + + + Unsupported system message + + - + `; @@ -142345,6484 +137433,6224 @@ exports[`Story Snapshots: SystemMessagesLargeFont should match snapshot 1`] = ` } > - - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - message removed - + > + diego.mello - - + + + message removed + + + - - + - - - - + + - - - - - - + + + - + + - + + diego.mello + + + - - - diego.mello - - - - joined the channel - + > + joined the channel - - + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - Pinned a message: - + > + diego.mello - - - + + + Pinned a message: + + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - left the channel - + > + diego.mello - - + + + left the channel + + + - - + - - - - - - + + - - - - - - - - - - diego.mello - - - - removed rocket.cat - + "borderRadius": 4, + "height": 26, + "width": 26, + } + } + transition={null} + width={26} + /> + + + + + + + + + + diego.mello - - + + + removed rocket.cat + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - added rocket.cat - + > + diego.mello - - + + + added rocket.cat + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - muted rocket.cat - + > + diego.mello - - + + + muted rocket.cat + + + - - + - - - - - - - - - - - - - - - - diego.mello - - - + + + - unmuted rocket.cat - + "borderRadius": 4, + "height": 26, + "width": 26, + } + } + transition={null} + width={26} + /> + + + + + + + + + + diego.mello - - + + + unmuted rocket.cat + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - defined rocket.cat as admin - + > + diego.mello - - + + + defined rocket.cat as admin + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - removed rocket.cat as admin - + > + diego.mello - - + + + removed rocket.cat as admin + + + - - + - - - - + + - - - - - - + + + - + + - + + diego.mello + + + - - - diego.mello - - - - changed room name to: New name - + > + changed room name to: New name - - + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room description to: new description - + > + diego.mello - - + + + changed room description to: new description + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room announcement to: new announcement - + > + diego.mello - - + + + changed room announcement to: new announcement + + + - - + - - - - - - + + - - - - - - - - - - diego.mello - - - - changed room topic to: new topic - + "borderRadius": 4, + "height": 26, + "width": 26, + } + } + transition={null} + width={26} + /> + + + + + + + + + + diego.mello - - + + + changed room topic to: new topic + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - changed room to public - + > + diego.mello - - + + + changed room to public + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - disabled E2E encryption for this room - + > + diego.mello - - + + + disabled E2E encryption for this room + + + - - + - - - - + + - - - - - - + + + - + + - + + diego.mello + + + - - - diego.mello - - - - enabled E2E encryption for this room - + > + enabled E2E encryption for this room - - + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - removed @rocket.cat from this team - + > + diego.mello - - + + + removed @rocket.cat from this team + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - added @rocket.cat to this team - + > + diego.mello - - + + + added @rocket.cat to this team + + + - - + - - - - + + - - - - - - + + + - + + - - - - diego.mello - - - - added #channel-name to this team - + "flex": 1, + "marginLeft": 10, + } + } + > + + + diego.mello - - + + + added #channel-name to this team + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - converted #channel-name to a team - + > + diego.mello - - + + + converted #channel-name to a team + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - converted #channel-name to channel - + > + diego.mello - - + + + converted #channel-name to channel + + + - - + - - - - + + - - - - - - + + + - + + - - - - diego.mello - - - - deleted #channel-name - + + + diego.mello - - + + + deleted #channel-name + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - removed #channel-name from this team - + > + diego.mello - - + + + removed #channel-name from this team + + + - - + - - - - + + - - - - - - + + + - + + - + - - - diego.mello - - - - was removed by ABAC - + > + diego.mello - - + + + was removed by ABAC + + + - - + - - - - + + - - - - - - + + + - + + - - - Unsupported system message - - - + Unsupported system message + + - + `; @@ -148836,472 +143664,452 @@ exports[`Story Snapshots: Temp should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Temp message - - - - - - - - - - Temp message - - - - - - + + - + - - - + + + `; @@ -149315,472 +144123,452 @@ exports[`Story Snapshots: TempLargeFont should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - - diego.mello - - - - - 10:00 AM - - - - - - - - Temp message - - - - - - + Temp message + + + - + - - - + + + `; @@ -149794,696 +144582,676 @@ exports[`Story Snapshots: ThumbnailFromServer should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - + + Title + + + - - - - - Title - - - - - - - Image text - - - - - - - + Image text + + + - - - - - - - this is a thumbnail - - - - + } + transition={null} + width={80} + /> + + + + + + this is a thumbnail + + + + + - + - - - + + + `; @@ -150497,696 +145265,676 @@ exports[`Story Snapshots: ThumbnailFromServerLargeFont should match snapshot 1`] } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - diego.mello - - - - - - 10:00 AM - - + + Title + + + - - - - - Title - - - - - - - Image text - - - - - - - + Image text + + + + - + + + + + + - - - - - this is a thumbnail - - - - - - + this is a thumbnail + + + - + - - - + + + `; @@ -151200,476 +145948,456 @@ exports[`Story Snapshots: TimeFormat should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10 November 2017 + + + + - - + - diego.mello - - - 10 November 2017 + Testing - - - - - - - - - - Testing - - - - - - + + - + - - - + + + `; @@ -151683,476 +146411,456 @@ exports[`Story Snapshots: TimeFormatLargeFont should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10 November 2017 + + + - - + - diego.mello + Testing - - - - - 10 November 2017 + - - - - - - - Testing - - - - - - - + - - - + + + `; @@ -152166,512 +146874,491 @@ exports[`Story Snapshots: Translated should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + other.user + + + 10:00 AM + - - + + + + + + + + - other.user - - - 10:00 AM + Message translated - - - - -  - - - - - - - - - - - Message translated - - - - - - + + - + - - - + + + `; @@ -152685,512 +147372,491 @@ exports[`Story Snapshots: TranslatedLargeFont should match snapshot 1`] = ` } > - - - + - + + + + + + + + + + + + - - + other.user + + + + + - - - - - - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - + + 10:00 AM + + + - - + - other.user + Message translated - - - - -  - - - - - - 10:00 AM + - - - - - - - Message translated - - - - - - - + - - - + + + `; @@ -153204,609 +147870,650 @@ exports[`Story Snapshots: TwoShortCustomFieldsWithMarkdown should match snapshot } > - - - + + + + + + + + + + + + + - - + diego.mello + + - - - - - - - - - + > + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - + + rocket.cat + + + + + + + Custom fields + + + + + + + Field 1 + - - - rocket.cat - - - - - - Custom fields - - - - - - - Field 1 + Value 1 - - - - - Value 1 - - - - - - + + + + + + Field 2 + + + + - Field 2 - - - - - - Value 2 - - - + Value 2 - - - + + + + + + + + + + + rocket.cat + + + + + + + Custom fields 2 + + + + + + + Field 1 + - - - rocket.cat - - - - - - Custom fields 2 - - - - - - - Field 1 + Value 1 - - - - - Value 1 - - - - - - + + + + + + Field 2 + + + + + - Field 2 - - - - - - - Value 2 - - - + }, + ] + } + > + Value 2 - - - + + + - + + + + + + - - - - - Message - - - - - - + Message + + + - + - - - + + + `; @@ -154521,1170 +149167,1190 @@ exports[`Story Snapshots: TwoShortCustomFieldsWithMarkdownLargeFont should match } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - diego.mello - - - - - - 10:00 AM - - + + rocket.cat + + + + + + + Custom fields + + + + + + + Field 1 + - - - rocket.cat - - - - - Custom fields - + Value 1 - - + + + + + Field 2 + + + - - Field 1 - - - - - Value 1 - - - - - - - - Field 2 - - - - + - - - - Value 2 - - - + Value 2 - - - + + + + + + + + + + + rocket.cat + + + + + + + Custom fields 2 + + + + + + + Field 1 + - - rocket.cat + + Value 1 + - - + + + + + Field 2 + + + - Custom fields 2 - - - - - - - - Field 1 - - - - - - Value 1 - - - - - - - - Field 2 - - - - - - - - Value 2 - - - + }, + ] + } + > + Value 2 - - - + + + - + + + + + + - - - - - Message - - - - - - + Message + + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: URL should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + + + + + Rocket.Chat - Free, Open Source, Enterprise Team Chat + + + Rocket.Chat is the leading open source team chat software solution. Free, unlimited and completely customizable with on-premises and SaaS cloud hosting. + + + + + + + Google + + + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + - + - - - - - -`; - -exports[`Story Snapshots: URL should match snapshot 1`] = ` - - - + + + - - + - - + - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - Rocket.Chat - Free, Open Source, Enterprise Team Chat - - - Rocket.Chat is the leading open source team chat software solution. Free, unlimited and completely customizable with on-premises and SaaS cloud hosting. - - - - - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - + /> + + - - - + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Message - - - - - - - - - Message - - - - - - - - - - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - + transition={null} + width={15} + /> - - + + + + + + + + Google + + + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + - + - - - - + + + - - + + - - - - + + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + + + + + + + + + + + + + + +`; + +exports[`Story Snapshots: URLImagePreview should match snapshot 1`] = ` + + + + + + + + + + + + + + - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + + + + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "flex": 1, + "flexDirection": "column", + "gap": 4, + }, + { + "backgroundColor": "#F7F8FA", + "borderColor": "#CBCED1", + "borderRadius": 4, + "borderWidth": 1, + "overflow": "hidden", + }, + ] + } + > + + + Google + + + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + + + - + - - - - - -`; - -exports[`Story Snapshots: URLImagePreview should match snapshot 1`] = ` - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={36} + /> - + + + + + + + - - - - - diego.mello - - - 10:00 AM - - - - - - - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - - + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "flexDirection": "row", + "flexShrink": 1, + "gap": 4, + } + } + testID="username-header-diego.mello" + > + + diego.mello + + + 10:00 AM + + + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: URLImagePreviewLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - - diego.mello - - - 10:00 AM - - - - - - + - + "color": "#6C727A", + }, + ] + } + > + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + - + - - - - - -`; - -exports[`Story Snapshots: URLImagePreviewLargeFont should match snapshot 1`] = ` - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + - - - - - diego.mello - - - - - - 10:00 AM - - - - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - - - + /> + + + 10:00 AM + + + - + - - - - + + + + +`; + +exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = ` + + + - - + + + + + + + + + + + + + - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - - - - - + Rocket.Chat - Free, Open Source, Enterprise Team Chat + + + Rocket.Chat is the leading open source team chat software solution. Free, unlimited and completely customizable with on-premises and SaaS cloud hosting. + - - - - diego.mello - - - - + "color": "#095AD2", + }, + ] + } + > + Google + - 10:00 AM + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - + - - - - - -`; - -exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = ` - - - + + + - - + - - + - - - - - - - - - - - - - - - - - diego.mello - - - - - + + - 10:00 AM - - - - - - Rocket.Chat - Free, Open Source, Enterprise Team Chat - - - Rocket.Chat is the leading open source team chat software solution. Free, unlimited and completely customizable with on-premises and SaaS cloud hosting. - - - - - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - + "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001&rc_token=abc&rc_uid=y8bd77ptZswPj3EW8", + }, + ] + } + style={ + { + "borderRadius": 4, + "height": 46.800000000000004, + "width": 46.800000000000004, + } + } + transition={null} + width={46.800000000000004} + /> - - - + + + - - - - - - - - - - - - - + diego.mello + + + + + + 10:00 AM + + + + + - + + Message + - - - + + - - - - diego.mello - - - - - 10:00 AM + Google - - - - - - - Message - - - - - - - - - - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + ] + } + > + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + - + - - - - + + + - - + + - - - - + - - - Google - - - Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. - - - - + "color": "#6C727A", + }, + ] + } + > + Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for. + - - + + - - - + + + `; @@ -160796,804 +155222,1333 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + jd + + + 10:00 AM + + + + - - - jd - - - 10:00 AM + Small username (2 chars) - - - - - - - - - - Small username (2 chars) - - - - - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + jd + + + 10:00 AM + + + + +  + + + +  + + + + +  + + + + +  + + + + +  + + + +  + + + + + + + Encrypted message + - - - - + + + - - + - + - - - + - - - + + + + + + - - - - jd - - - 10:00 AM - - - - -  - -  + john.doe - - -  + 10:00 AM + + + - + -  - + { + "color": "#2F343D", + }, + ] + } + > + + + Medium username (8 chars) + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + john.doe + + + 10:00 AM + + + + - -  - - - +  + + + -  - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - - + + + +  + + + + - Encrypted message - - + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + +  + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - john.doe - - - 10:00 AM - - - - - - - - - - - Medium username (8 chars) - - - - - - - - - - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + ] + } + testID="message-encrypted" + > + Encrypted message + - - - - + + + - - + - + - - - + - - - + + + + + + - - - - john.doe - - - 10:00 AM - - - - -  - - -  - - - -  + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - -  + 10:00 AM - -  - - - -  - - - - - - - Encrypted message - + /> - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - 10:00 AM + Long username (22 chars) - should truncate - - - - - - - - - - Long username (22 chars) - should truncate - - - - - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername + + + 10:00 AM + + + + +  + + + +  + + + + +  + + + + +  + + + + +  + + + +  + + + + + + + Encrypted message + - - - - + + + + +`; + +exports[`Story Snapshots: WithAlias should match snapshot 1`] = ` + + + - - + - + - - - + - - - + + + + + + - - - - johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername - - - 10:00 AM - - - - -  - + Diego Mello + + @ + diego.mello + + + -  + 10:00 AM + + + - + -  - + { + "color": "#2F343D", + }, + ] + } + > + + + Message + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - -  - - - -  + Diego Mello + + @ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + - - + 10:00 AM + + + -  - + /> - - - - Encrypted message - + + + + + Message + + + + + - + - + `; -exports[`Story Snapshots: WithAlias should match snapshot 1`] = ` +exports[`Story Snapshots: WithAliasLargeFont should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - - + Diego Mello + - - - - - - - - + @ + diego.mello + + + + + 10:00 AM + + + - - + - - Diego Mello - - @ - diego.mello - - + } + > - 10:00 AM + Message - - - - - - - - - - Message - - - - - - + + - + - - - - + + + - - + + + + + + + + + + + + + - - - + Diego Mello + - - - - - - - - + @ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + 10:00 AM + + + - - + - - Diego Mello - - @ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - 10:00 AM - - - - - - - - - - - Message - - - - - - + Message + + + - + - - - + + + `; -exports[`Story Snapshots: WithAliasLargeFont should match snapshot 1`] = ` +exports[`Story Snapshots: WithAudio should match snapshot 1`] = ` - - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - + This is a description + + - - - - - - - + > + + + + + - Diego Mello - - @ - diego.mello - +  - - - 10:00 AM - - - + - - - - Message - - - + /> + + - + - - - - + + + - - + + - - + - - - - - - - - - - - + > + Header false + + + + - Diego Mello - - @ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - +  - - - 10:00 AM - - - + - - - - Message - - - + /> + + - + + - - - - - -`; - -exports[`Story Snapshots: WithAudio should match snapshot 1`] = ` - - - + + + - - + + - - + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + multi file + + + - diego.mello +  - + + - 10:00 AM - - - - - - + /> - - - - - This is a description - - - - - - - - -  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + [ + { + "borderRadius": 2, + "height": 4, + "width": "100%", + }, + [Function], + { + "backgroundColor": "#156FF5", + }, + ] + } + /> + + + + + + + +  + + + + - - - - Header false - - - + /> - - -  - - - - - - - - - - - - + /> - - + + - - - - + + + - - + + - - - - - - - multi file - - - - + [ + { + "color": "#FFFFFF", + "fontSize": 24, + }, + [ + { + "lineHeight": 24, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + @@ -167520,645 +162503,340 @@ exports[`Story Snapshots: WithAudio should match snapshot 1`] = ` style={ [ { - "alignItems": "center", - "borderRadius": 4, - "borderWidth": 1, - "flex": 1, - "flexDirection": "row", - "height": 56, + "borderRadius": 2, + "height": 4, + "width": "100%", }, { - "backgroundColor": "#FFFFFF", - "borderColor": "#EBECEF", + "backgroundColor": "#CBCED1", }, ] } > - -  - - - + + - - - - - - - - - + "backgroundColor": "#D1EBFE", + }, + [Function], + ] + } + /> - + + + + + + - - + - -  - - - + - - - - - - - - + transition={null} + width={15} + /> - - + + - - - - - - - - - - - - - - - - - - - - - - + +  + + + + @@ -168166,576 +162844,235 @@ exports[`Story Snapshots: WithAudio should match snapshot 1`] = ` style={ [ { - "alignItems": "center", - "borderRadius": 4, - "borderWidth": 1, - "flex": 1, - "flexDirection": "row", - "height": 56, + "borderRadius": 2, + "height": 4, + "width": "100%", }, { - "backgroundColor": "#FFFFFF", - "borderColor": "#EBECEF", + "backgroundColor": "#CBCED1", }, ] } > - -  - - - - - - - - - - - - - - - - - - - This is a description - - - - - - + /> - - -  - - - - - - - - - - - - + /> + + + + + + + +  + + + + @@ -168743,250 +163080,77 @@ exports[`Story Snapshots: WithAudio should match snapshot 1`] = ` style={ [ { - "alignItems": "center", - "borderRadius": 4, - "borderWidth": 1, - "flex": 1, - "flexDirection": "row", - "height": 56, + "borderRadius": 2, + "height": 4, + "width": "100%", }, { - "backgroundColor": "#FFFFFF", - "borderColor": "#EBECEF", + "backgroundColor": "#CBCED1", }, ] } > - -  - - - + + - - - - - - - - - + "backgroundColor": "#D1EBFE", + }, + [Function], + ] + } + /> - - + + - - - + + + `; @@ -169000,303 +163164,526 @@ exports[`Story Snapshots: WithAudioLargeFont should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - - + This is a description + + - - - - - - - + > + + + + + - diego.mello +  - - - 10:00 AM - - - + - - - - This is a description - - - - - - + /> - - -  - - - - - - - - - - - - + /> - + - - - - + + + - - + + - - - - - - - - - First message - - - - - - + [ + { + "flexShrink": 1, + "fontSize": 16, + "lineHeight": 22, + }, + { + "color": "#2F343D", + }, + ] + } + > + First message + + + - - + + - - - - + + + - - + + - - + + + + This is a description + + + + + + +  + + + + - - - - This is a description - - - + /> - - -  - - - - - - - - - - - - + /> - - + + - - - - + + + - - + + - - + +  + + + + @@ -170622,422 +164879,573 @@ exports[`Story Snapshots: WithAudioLargeFont should match snapshot 1`] = ` style={ [ { - "alignItems": "center", - "borderRadius": 4, - "borderWidth": 1, - "flex": 1, - "flexDirection": "row", - "height": 56, + "borderRadius": 2, + "height": 4, + "width": "100%", }, { - "backgroundColor": "#FFFFFF", - "borderColor": "#EBECEF", + "backgroundColor": "#CBCED1", }, ] } > - -  - - - + + - - - - - - - - - + "backgroundColor": "#D1EBFE", + }, + [Function], + ] + } + /> - - + + - - - - + + + - - + + - + + > + + + + multi file + + + + + + + + +  + + + + + + + + + + + + + + - - - + + - - multi file - - - - + } + transition={null} + width={15} + /> + + + + + + + +  + + @@ -171098,576 +165676,235 @@ exports[`Story Snapshots: WithAudioLargeFont should match snapshot 1`] = ` style={ [ { - "alignItems": "center", - "borderRadius": 4, - "borderWidth": 1, - "flex": 1, - "flexDirection": "row", - "height": 56, + "borderRadius": 2, + "height": 4, + "width": "100%", }, { - "backgroundColor": "#FFFFFF", - "borderColor": "#EBECEF", + "backgroundColor": "#CBCED1", }, ] } > - -  - - - - - - - - - - - - - - - - - - - This is a description - - - - - - + /> - - -  - - - - - - - - - - - - + /> + + + + + + + +  + + + + @@ -171675,811 +165912,557 @@ exports[`Story Snapshots: WithAudioLargeFont should match snapshot 1`] = ` style={ [ { - "alignItems": "center", - "borderRadius": 4, - "borderWidth": 1, - "flex": 1, - "flexDirection": "row", - "height": 56, + "borderRadius": 2, + "height": 4, + "width": "100%", }, { - "backgroundColor": "#FFFFFF", - "borderColor": "#EBECEF", + "backgroundColor": "#CBCED1", }, ] } > - -  - - - + + - - - - - - - - - + "backgroundColor": "#D1EBFE", + }, + [Function], + ] + } + /> - + + + + + + + + + + +`; + +exports[`Story Snapshots: WithFile should match snapshot 1`] = ` + + + + + + + + + + + - - + underlayColor="#E4E7EA" + > + + + + + + + - - - - - - - -`; - -exports[`Story Snapshots: WithFile should match snapshot 1`] = ` - - - - - - - - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - - diego.mello - - - 10:00 AM - - - - - - - - - - - - - File.pdf - - - - - - - + /> - - This is a description - - - - - - + > + + File.pdf + + + + + + + + + + + + This is a description + + + - - + + - + - - - - + + + - - + + - - - - - - - - - - File.pdf - - - - - - - + /> - This is a description + File.pdf - - - - + + + + - - + + - - - - - - - File.pdf - - - - - - + } + transition={null} + width={15} + /> + + + + + + + + + - This is a description + File.pdf - - - + + + + + This is a description + + + + + + + - - + + - - - + + + `; @@ -173293,554 +167317,473 @@ exports[`Story Snapshots: WithFileLargeFont should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - - diego.mello - - - - - - 10:00 AM - - - - - - - - - - File.pdf - - - - - - - + /> - This is a description + File.pdf - - - + + + + + This is a description + + + + + + + - + - - - - + + + - - + + - - + - - - - - - - - File.pdf - - - - - - + File.pdf + + + + + + + + + + + This is a description + - - - - This is a description - - - - - - + } + transition={null} + width={15} + /> - - + + - - + + - - - + + + `; @@ -174400,1109 +168384,1177 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - + This is a description + + - - - - - - - + > + + + + + - - diego.mello - - + - 10:00 AM - - - - - - + /> - - - - - This is a description - - - - - - - - - - - - - -  - - - - - +  + + - + - - - - + + + - - + - - + - - + + - - - - - - - + } + transition={null} + width={36} + /> + + + + + + + + + + diego.mello + + + 10:00 AM + + + + - - + - diego.mello - - - 10:00 AM + Message, not description - - - + + + + + + - - - - - Message, not description - - + This is a description - - - - - - - - This is a description - - - - - - - - + + + + + + + - + - - - - -  - - - + + - - + "lineHeight": 54, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + + + + + + This is a description + - - - - This is a description - - - - - - - - + + + + + + + - + - - - - -  - - - + + - - + "lineHeight": 54, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + + + + + + + + + - - - - - -  - - - - - + "lineHeight": 54, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - + - - - - + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + multi file + + + + + - - diego.mello - + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > - 10:00 AM +  + + - - - - - - multi file - - - - - - - + - - - - -  - - - - - - + - + - - - - -  - - - - - + "lineHeight": 54, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - + - - - - + + + - - - + + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - diego.mello - - + This is a description for Base64 Attachment + + - 10:00 AM + } + } + > + + - - + - - - - - This is a description for Base64 Attachment - - - - - - - - + - + + - - - - - - - - + "padding": 16, + }, + undefined, + ] + } + /> + - + - - - + + + `; @@ -177759,2525 +171663,2445 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - diego.mello - - - - - - 10:00 AM - + + This is a description + + + + + + - - - - - This is a description - - - - - - - - - - -  - - - - - +  + + - + - - - - + + + - - + + - - - - - - - - - Header false - - - - - + + + + + + - + + + - - - - -  - - - - - + "lineHeight": 70.2, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - - + + - - - - + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + multi file + + + + - + - diego.mello - + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > - 10:00 AM +  + + - - - - - - multi file - - - - - - - + - - - - -  - - - - - - + - + - - - - -  - - - - - + "lineHeight": 54, + }, + undefined, + ], + { + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + diego.mello + + + + + + 10:00 AM + + + + + - - - - + This is a description for Base64 Attachment + + - - - - - - - + > + + + + + - - diego.mello - - - - - - 10:00 AM - - - + /> - - - - - This is a description for Base64 Attachment - - - - - - - - + - + - - - - - - - - + "padding": 16, + }, + undefined, + ] + } + /> + - + - - - + + + `; @@ -180291,1744 +174115,1684 @@ exports[`Story Snapshots: WithVideo should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + 10:00 AM + + + + - - diego.mello - - - 10:00 AM - - - - - - - + + This is a description + - - - - This is a description - - - - - - + } + transition={null} + width={15} + /> - + + + + + - - - -  - - - - + }, + { + "borderColor": "#CBCED1", + "borderWidth": 1, + }, + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > + +  + - + - - - - + + + - - + + - - - - - - - - - Header false - - - - - + + + + + + + - - - -  - - - - + "fontFamily": "custom", + "fontStyle": "normal", + "fontWeight": "normal", + }, + {}, + ] + } + > +  + - - + + - - - - + + + - - + + + + + + + + + + + + + - - + diego.mello + + - + 10:00 AM + + + + + + + + - - - - - - - + "color": "#2F343D", + }, + ] + } + > + multi file + + + - + - diego.mello - + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > - 10:00 AM - - - +  + + + + - - - - - - multi file - - - - - - - - - - -  - - - - - - + - - - -  - - - - + }, + { + "borderColor": "#CBCED1", + "borderWidth": 1, + }, + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > + +  + - + - - - + + + `; @@ -182042,1921 +175806,1861 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = ` } > - - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - diego.mello - - - - - - 10:00 AM - - - - + + This is a description + - - - - This is a description - - - - - - + } + transition={null} + width={15} + /> - + + + + + - - - -  - - - - + }, + { + "borderColor": "#CBCED1", + "borderWidth": 1, + }, + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > + +  + - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - diego.mello - - - - - - 10:00 AM - - + /> - - - - - -  - - - - + }, + { + "borderColor": "#CBCED1", + "borderWidth": 1, + }, + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > + +  + - + - - - - + + + - - + + + + + + + + + + + + + - - - - - - - - - - - + > + diego.mello + + + + 10:00 AM + + + - - + - diego.mello + multi file - - - - - 10:00 AM + + + + + - - - - - - multi file - - - - - - - + - - - -  - - - - - + - + + + + + + + - - - -  - - - - + }, + { + "borderColor": "#CBCED1", + "borderWidth": 1, + }, + ], + { + "alignItems": "center", + "justifyContent": "center", + "position": "absolute", + }, + ] + } + > + +  + - + - - - + + + `; @@ -183970,238 +177674,218 @@ exports[`Story Snapshots: WithoutHeader should match snapshot 1`] = ` } > - - - + + - - - - - - - - - Message - - - - - - + Message + + + - - + + - - - + + + `; @@ -184215,238 +177899,218 @@ exports[`Story Snapshots: WithoutHeaderLargeFont should match snapshot 1`] = ` } > - - - + + - - - - - - - - - Message - - - - - - + Message + + + - - + + - - - + + + `; diff --git a/app/containers/message/components/stories/Attachments.stories.tsx b/app/containers/message/components/stories/Attachments.stories.tsx index 8fad45b0867..61c2e50f479 100644 --- a/app/containers/message/components/stories/Attachments.stories.tsx +++ b/app/containers/message/components/stories/Attachments.stories.tsx @@ -4,15 +4,14 @@ import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; import { selectServerSuccess } from '~/actions/server'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import Attachments from '../Attachments/Attachments'; const mockMessageContext: Partial = { - baseUrl: 'https://open.rocket.chat', - user: { id: 'user-id', username: 'rocket.cat', token: 'token' }, - showAttachment: undefined + handlers: { showAttachment: undefined } }; const mockItem = { id: 'msg-id', msg: '', u: { username: 'rocket.cat' }, autoTranslate: false } as unknown as TAnyMessageModel; @@ -50,6 +49,7 @@ const MOCK_MULTIPLE_IMAGES = [ const createServerStore = (version: string) => { const store = createMockedStore(); store.dispatch(selectServerSuccess({ server: 'https://open.rocket.chat', version, name: 'Test' })); + store.dispatch(setUser({ id: 'user-id', username: 'rocket.cat', token: 'token' })); return store; }; diff --git a/app/containers/message/components/stories/Broadcast.stories.tsx b/app/containers/message/components/stories/Broadcast.stories.tsx index 0e468e22dcf..f180e787c0e 100644 --- a/app/containers/message/components/stories/Broadcast.stories.tsx +++ b/app/containers/message/components/stories/Broadcast.stories.tsx @@ -2,12 +2,14 @@ import { type ReactNode } from 'react'; import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import BroadcastLeaf from '../Broadcast'; const store = createMockedStore(); +store.dispatch(setUser({ id: 'reader-id', username: 'rocket.cat' })); const otherAuthorItem = { id: 'msg-id', @@ -24,9 +26,7 @@ const ownAuthorItem = { } as unknown as TAnyMessageModel; const room: Partial = { - broadcast: true, - user: { id: 'reader-id', username: 'rocket.cat' }, - replyBroadcast: () => {} + broadcast: true }; const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/CallButton.stories.tsx b/app/containers/message/components/stories/CallButton.stories.tsx index e3c462f620d..516c1c16f53 100644 --- a/app/containers/message/components/stories/CallButton.stories.tsx +++ b/app/containers/message/components/stories/CallButton.stories.tsx @@ -16,9 +16,7 @@ const item = { autoTranslate: false } as unknown as TAnyMessageModel; -const room: Partial = { - handleEnterCall: () => {} -}; +const room: Partial = {}; const StoryWrapper = ({ children }: { children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/CollapsibleQuote.stories.tsx b/app/containers/message/components/stories/CollapsibleQuote.stories.tsx index bd10299b60c..f3cea6a35be 100644 --- a/app/containers/message/components/stories/CollapsibleQuote.stories.tsx +++ b/app/containers/message/components/stories/CollapsibleQuote.stories.tsx @@ -19,7 +19,7 @@ const item = { const StoryWrapper = ({ children }: { children: ReactNode }) => ( - + {children} diff --git a/app/containers/message/components/stories/Content.stories.tsx b/app/containers/message/components/stories/Content.stories.tsx index 9614832bb49..e4a4d1909a3 100644 --- a/app/containers/message/components/stories/Content.stories.tsx +++ b/app/containers/message/components/stories/Content.stories.tsx @@ -2,12 +2,14 @@ import { type ReactNode } from 'react'; import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import ContentLeaf from '../Content'; const store = createMockedStore(); +store.dispatch(setUser({ id: 'reader-id', username: 'reader' })); const item = { id: 'msg-id', @@ -17,9 +19,7 @@ const item = { autoTranslate: false } as unknown as TAnyMessageModel; -const room: Partial = { - user: { id: 'reader-id', username: 'reader' } -}; +const room: Partial = {}; const StoryWrapper = ({ children }: { children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/Discussion.stories.tsx b/app/containers/message/components/stories/Discussion.stories.tsx index 25b73fe9637..a4b176b3db8 100644 --- a/app/containers/message/components/stories/Discussion.stories.tsx +++ b/app/containers/message/components/stories/Discussion.stories.tsx @@ -2,12 +2,14 @@ import { type ReactNode } from 'react'; import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import DiscussionLeaf from '../Discussion'; const store = createMockedStore(); +store.dispatch(setUser({ id: 'reader-id', username: 'reader' })); const item = { id: 'msg-id', @@ -18,10 +20,7 @@ const item = { autoTranslate: false } as unknown as TAnyMessageModel; -const room: Partial = { - user: { id: 'reader-id', username: 'reader' }, - onDiscussionPress: () => {} -}; +const room: Partial = {}; const StoryWrapper = ({ children }: { children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/Message.stories.tsx b/app/containers/message/components/stories/Message.stories.tsx index c8c14790716..fee36f293c1 100644 --- a/app/containers/message/components/stories/Message.stories.tsx +++ b/app/containers/message/components/stories/Message.stories.tsx @@ -17,6 +17,8 @@ import { import { mockedStore as store } from '~/reducers/mockedStore'; import { updateSettings } from '~/actions/settings'; import { setCustomEmojis } from '~/actions/customEmojis'; +import { setUser } from '~/actions/login'; +import { selectServerSuccess } from '~/actions/server'; import { createMessageActionStore, MessageActionStoreContext } from '~/containers/message/stores/MessageActionStore'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; @@ -55,6 +57,8 @@ const date = new Date(2017, 10, 10, 10); const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; +store.dispatch(setUser(user)); +store.dispatch(selectServerSuccess({ server: baseUrl, version: '', name: '' })); store.dispatch(updateSettings('API_Embed', true)); store.dispatch( setCustomEmojis({ @@ -81,22 +85,9 @@ const containerHandlers = { }; const roomHandlers: Partial = { - user, - baseUrl, reactionInit: () => {}, - replyBroadcast: () => {}, - onReactionPress: () => {}, - onEncryptedPress: () => {}, - onDiscussionPress: () => {}, - onThreadPress: () => {}, - onReactionLongPress: () => {}, - onAnswerButtonPress: () => {}, jumpToMessage: () => {}, - navToRoomInfo: () => {}, - showAttachment: undefined, - blockAction: undefined, - handleEnterCall: undefined, - fetchThreadName: undefined + handlers: { navToRoomInfo: () => {}, showAttachment: undefined } }; export default { diff --git a/app/containers/message/components/stories/MessageAvatar.stories.tsx b/app/containers/message/components/stories/MessageAvatar.stories.tsx index 0fd8676ec2f..f7e7cb1b115 100644 --- a/app/containers/message/components/stories/MessageAvatar.stories.tsx +++ b/app/containers/message/components/stories/MessageAvatar.stories.tsx @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; import { selectServerRequest } from '~/actions/server'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; @@ -10,6 +11,7 @@ import MessageAvatarLeaf from '../MessageAvatar'; const store = createMockedStore(); store.dispatch(selectServerRequest('https://open.rocket.chat', '7.0.0')); +store.dispatch(setUser({ id: 'reader-id', username: 'reader' })); const item = { id: 'msg-id', @@ -18,9 +20,7 @@ const item = { autoTranslate: false } as unknown as TAnyMessageModel; -const room: Partial = { - user: { id: 'reader-id', username: 'reader' } -}; +const room: Partial = {}; const StoryWrapper = ({ children }: { children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/Reactions.stories.tsx b/app/containers/message/components/stories/Reactions.stories.tsx index 8c63afb7ac1..a370d04547d 100644 --- a/app/containers/message/components/stories/Reactions.stories.tsx +++ b/app/containers/message/components/stories/Reactions.stories.tsx @@ -2,12 +2,14 @@ import { type ReactNode } from 'react'; import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import ReactionsLeaf from '../Reactions'; const store = createMockedStore(); +store.dispatch(setUser({ id: 'reader-id', username: 'rocket.cat' })); const item = { id: 'msg-id', @@ -21,9 +23,6 @@ const item = { } as unknown as TAnyMessageModel; const room: Partial = { - user: { id: 'reader-id', username: 'rocket.cat' }, - onReactionPress: () => {}, - onReactionLongPress: () => {}, reactionInit: () => {} }; diff --git a/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx b/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx index 475426304f0..d603ce57648 100644 --- a/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx +++ b/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx @@ -26,9 +26,7 @@ const plainItem = { autoTranslate: false } as unknown as TAnyMessageModel; -const room: Partial = { - onEncryptedPress: () => {} -}; +const room: Partial = {}; const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/RightIcons/Translated.stories.tsx b/app/containers/message/components/stories/RightIcons/Translated.stories.tsx index 56f24ffd277..a1f91317a11 100644 --- a/app/containers/message/components/stories/RightIcons/Translated.stories.tsx +++ b/app/containers/message/components/stories/RightIcons/Translated.stories.tsx @@ -46,16 +46,14 @@ export default { // An auto-translated message from another user renders the language icon. export const Translated = () => ( - + ); // A message that is not translated renders nothing. export const TranslatedHidden = () => ( - + ); diff --git a/app/containers/message/components/stories/Thread.stories.tsx b/app/containers/message/components/stories/Thread.stories.tsx index cb3a16d5265..9bed22e049b 100644 --- a/app/containers/message/components/stories/Thread.stories.tsx +++ b/app/containers/message/components/stories/Thread.stories.tsx @@ -2,12 +2,14 @@ import { type ReactNode } from 'react'; import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import ThreadLeaf from '../Thread'; const store = createMockedStore(); +store.dispatch(setUser({ id: 'reader-id', username: 'reader' })); const item = { id: 'msg-id', @@ -20,8 +22,6 @@ const item = { } as unknown as TAnyMessageModel; const room: Partial = { - user: { id: 'reader-id', username: 'reader' }, - onThreadPress: () => {}, isThreadRoom: false }; diff --git a/app/containers/message/components/stories/Urls.stories.tsx b/app/containers/message/components/stories/Urls.stories.tsx index 5b6f8173aa9..e062c400a7f 100644 --- a/app/containers/message/components/stories/Urls.stories.tsx +++ b/app/containers/message/components/stories/Urls.stories.tsx @@ -3,6 +3,8 @@ import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; import { updateSettings } from '~/actions/settings'; +import { setUser } from '~/actions/login'; +import { selectServerSuccess } from '~/actions/server'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; @@ -10,6 +12,8 @@ import UrlsLeaf from '../Urls'; const store = createMockedStore(); store.dispatch(updateSettings('API_Embed', true)); +store.dispatch(setUser({ id: 'reader-id', username: 'reader', token: 'token' })); +store.dispatch(selectServerSuccess({ server: 'https://open.rocket.chat', version: '', name: '' })); const item = { id: 'msg-id', @@ -26,10 +30,7 @@ const item = { ] } as unknown as TAnyMessageModel; -const room: Partial = { - user: { id: 'reader-id', username: 'reader', token: 'token' }, - baseUrl: 'https://open.rocket.chat' -}; +const room: Partial = {}; const StoryWrapper = ({ children }: { children: ReactNode }) => ( diff --git a/app/containers/message/components/stories/User.stories.tsx b/app/containers/message/components/stories/User.stories.tsx index 9019b8f405c..0cf29229b1f 100644 --- a/app/containers/message/components/stories/User.stories.tsx +++ b/app/containers/message/components/stories/User.stories.tsx @@ -2,12 +2,14 @@ import { type ReactNode } from 'react'; import { Provider } from 'react-redux'; import { createMockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; import { type TAnyMessageModel } from '~/definitions'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import UserLeaf from '../User'; const store = createMockedStore(); +store.dispatch(setUser({ id: 'reader-id', username: 'reader' })); const item = { id: 'msg-id', @@ -21,7 +23,6 @@ const item = { } as unknown as TAnyMessageModel; const room: Partial = { - user: { id: 'reader-id', username: 'reader' }, timeFormat: 'LT' }; diff --git a/app/containers/message/docs/ARCHITECTURE.md b/app/containers/message/docs/ARCHITECTURE.md deleted file mode 100644 index d888b33ac6b..00000000000 --- a/app/containers/message/docs/ARCHITECTURE.md +++ /dev/null @@ -1,104 +0,0 @@ -# Message Component Architecture - -Load-bearing reference for `app/containers/message/`: how one Message row is provided, composed, and rendered. Domain terms (**Message Action**, **Message Action State**, **Positional State**, **Message Preview**) are defined in the repo glossary `CONTEXT.md` — this document uses them as defined there. Post-refactor (PR #7455): the module moved from a single class-component prop chain to three scoped Zustand stores plus a function-component render tree. - ---- - -## Purpose & entry point - -The module renders **one Message row**. The public entry point is the default export of `app/containers/message/index.tsx`: - -```tsx - - - - -``` - -`MessageProvider` seeds the per-row `MessageStore` (see below); `MessageTouchable` is the pressable row itself; `MessageSeparator` (a sibling, not part of this module) renders the date/unread divider above the row. - -This default export is consumed as `renderRow` from three list contexts — `RoomView` (the main Message Window), `MessagesView` (pinned/starred lists), `SearchMessagesView` (search results) — plus one non-list context: `components/Preview.tsx` wraps a single Message with its own `MessageRoomProvider` for a **Message Preview** rendered outside a Room (forward-message target picker, quoted-message preview in markdown). A `highlighted` boolean and `isPreview` flag are the only pieces of **Positional State** and preview-mode state this module receives from its caller — the Message Window itself (jump target, scroll position) is owned by `RoomView`, not here. - ---- - -## The three stores - -| Store | File | Scope | Provider mounted at | Lifetime | -| ---------------------- | ------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------- | -| **MessageStore** | `stores/MessageStore.tsx` | One Message record (`item`) + its previous neighbor | `index.tsx`, once per row | Row mount → unmount | -| **MessageRoomStore** | `stores/MessageRoomStore.tsx` | Room-scoped context: handlers, `rid`, `baseUrl`, user, settings | `RoomView`, `MessagesView`, `SearchMessagesView`, `ModalBlockView`, `Preview.tsx` — wraps the whole list/row | Room/view mount → unmount | -| **MessageActionStore** | `stores/MessageActionStore.tsx` | The active **Message Action** (Quote/Edit/React) and its target(s) | `RoomView` only, via `RoomProviders` — one per Room, wraps List **and** the composer | Room mount → unmount | - -### Why three, not one - -Each store changes at a different frequency and has a different audience, and collapsing them would force every row to re-render on changes it doesn't care about: - -- **MessageStore** changes per-row, on that row's own WatermelonDB record emitting (`experimentalSubscribe`) — a `tick` counter bumps and only that row's selectors re-run. `previousItem` doubles as the neighbor lookup used by grouping/thread-position logic. -- **MessageRoomStore** is mostly static for the life of a Room: navigation callbacks, `rid`, `baseUrl`, and the logged user are captured once (see `FROZEN_KEYS`) and never resynced, because they're expected to be referentially stable for the Room's lifetime. A second, explicitly reactive tail (`timeFormat`, `autoTranslateRoom`, `autoTranslateLanguage`, `archived`, `broadcast`, `isReadReceiptEnabled`, `Message_GroupingPeriod`) is pushed on every render via a dependency-gated `useEffect`, because those _can_ change mid-session (e.g. a Room gets archived). -- **MessageActionStore** is shared Room-wide state that both the Message list (to highlight the row being edited) and `MessageComposer` (to know what's being quoted/edited) read — it has to live above both, not per-row. - -### Granular selectors + `useShallow` + React Compiler - -Every store exposes narrow, single-purpose hooks (`useReactions`, `useBlocks`, `useMessageAuthor`, `useIsEncrypted`, …) instead of one broad "give me the message" hook — each leaf component subscribes to only the fields it renders, so a field it doesn't use changing elsewhere on the record never re-renders it. Hooks returning more than one field use Zustand's `useShallow` so a same-value re-tick doesn't produce a new object reference (`useBlocks`, `useThreadData`, `useMessageAuthor`, `useContentData`, etc.). Memoization comes from React Compiler (`babel-plugin-react-compiler`, `compilationMode: 'infer'`) rather than hand-written `useMemo`/`useCallback`. - -Two dev-only guards protect this pattern from silent regressions (both no-op in production builds): - -- `useMessageFieldDev` (MessageStore) warns once if a `useMessageField` selector returns a fresh-but-shallow-equal object/array on an unchanged tick — a sign someone wrote an inline `item => ({ a: item.a })` instead of a `useShallow` domain hook. Field stability itself depends on the model's `@json(..., { memo: true })` decorators (`app/lib/database/model/Message.js`), guarded separately by `Message.memo.test.ts`. -- `useFrozenHandlersGuardDev` (MessageRoomStore) warns once if any `FROZEN_KEYS` value's identity changes after mount, since the provider captures those once and never re-syncs them. - -### Inert-store fallback vs. throwing - -Rows can render outside a full provider stack (a **Message Preview** has no `MessageActionProvider`; some tests mount only `MessageStore`/`MessageRoomStore`). The three stores disagree deliberately on how to fail: - -- **MessageStore** and **MessageRoomStore** hooks throw (`'Message hooks must be used within a MessageProvider'` / `'...MessageRoomProvider'`) if no provider is present — every row and every Room-scoped view is expected to always supply both. -- **MessageActionStore** distinguishes by hook: `useMessageAction()` throws without a provider, but `useIsBeingEdited(messageId)` falls back to a shared `inertStore` (`action: null`, and every action method throws `NO_OP_ACTIONS`) — a Message Preview rendered without a `MessageActionProvider` can ask "am I being edited?" and safely get `false` instead of crashing, since it can never actually enter an edit session. - ---- - -## Component composition - -```mermaid -flowchart TD - A["MessageContainer (index.tsx)"] --> B["MessageProvider (MessageStore)"] - B --> C["MessageTouchable"] - C -->|"hasError / isInfo"| D["Message (bare, no Touch wrapper)"] - C -->|otherwise| E["Touch (press/long-press gate + a11y)"] - E --> D - D -->|"isThreadReply / isThreadSequential / isInfo / isIgnored"| F["CompactMessage"] - D -->|otherwise| G["FullMessage"] - F --> H["MessageAvatar (small) + Content [+ Attachments if pinned info]"] - G --> I["MessageAvatar + Layout + RightIcons (hidden when grouped)"] - I --> J["Layout dispatch"] - J -->|blocks| K["BlocksLayout"] - J -->|jitsi_call_started| L["JitsiLayout"] - J -->|discussion-created| M["DiscussionLayout"] - J -->|isPreview| N["PreviewLayout"] - J -->|default| O["StandardLayout"] - O --> P["User + Time + ContentLayout + Thread + Reactions + Broadcast"] - P --> Q["Content dispatch"] - Q -->|isInfo| R["InfoContent"] - Q -->|isIgnored| S["IgnoredContent"] - Q -->|isEncrypted| T["EncryptedContent"] - Q -->|"tmid, not thread room"| U["PreviewContent"] - Q -->|has text| V["MarkdownContent"] - O --> W["Attachments (Image/Audio/Video/AttachedActions/CollapsibleQuote/Reply)"] -``` - -- **MessageTouchable** (`components/Touchable/MessageTouchable.tsx`) is the press/long-press gate and the a11y surface for the row: it reads `useMessageTouchable()`'s derived `tappable`/`longPressable`/`revealsIgnored` flags plus `useIsBeingEdited` (for the edit-mode highlight color) and builds the accessibility label/hint/actions. Error and Info Messages skip the `Touch` wrapper entirely — the whole row isn't pressable, though sub-elements (links, attachments) still are through their own handlers. -- **`Touch`** (`components/Touchable/Touch.tsx`) is the actual platform touchable (`TouchableOpacity` on iOS, `TouchableHighlight` on Android) wrapped for `react-native-external-keyboard` D-pad focus; it carries no Message-specific logic. -- **`Message`** (`components/Message/Message.tsx`) is the density switch: `CompactMessage` for a **Thread Message**'s in-parent-room preview (`isThreadReply`/`isThreadSequential`), an **Info Message**, or an Ignored Message; `FullMessage` otherwise. -- **`FullMessage`** renders the avatar, dispatches to `Layout`, and appends `RightIcons` only when the row shows its own **Message Header** (`!isHeader` from `useMessageGrouping`) — a **Grouped Message** has no right-icon row. -- **`CompactMessage`** renders a small avatar and `Content` directly (no `Layout` dispatch), with an extra read-only `Attachments` render for a pinned Info Message (`t === 'message_pinned'`) and an optional `RepliedThread` header when it's the first Thread Message in a run. -- **`Layout`** (`components/Layout/index.tsx`) picks among `BlocksLayout` (a Blocks Message from an App), `JitsiLayout`/`DiscussionLayout` (their respective System Message types), `PreviewLayout` (a **Message Preview**), and `StandardLayout` (everything else), all wrapped in `WidthAwareView` for width-dependent children (images, markdown wrapping). -- **`Content`** (`components/Content/index.tsx`) is a second, narrower dispatch inside `StandardLayout`/`PreviewLayout`/`CompactMessage` for the body itself: Info → Ignored → Encrypted → thread-preview (`PreviewContent`, only outside a Thread room) → markdown, in that priority order. -- **`Attachments`** (`components/Attachments/Attachments.tsx`) filters by `variant` — `content` (the default) keeps non-quote attachments and maps each to `Image`/`Audio`/`Video`/`AttachedActions`/`CollapsibleQuote`/`Reply` by shape (`image_url`, `audio_url`, `video_url`, `actions`, `collapsed`, nested `attachments`), `quote` keeps quote attachments and renders each as a `Reply`. It owns the nested-quote recursion: `Reply` takes the nested lists as `children`, so the recursion stays inside this one module. Callers pick the variant directly; there is no separate `Quote` component. -- **`RightIcons`** (`components/RightIcons/index.tsx`) is a flat row of independent flag icons — `Pinned`, `Encrypted`, `Edited`, `MessageError`, `Translated`, `ReadReceipt` — each reading its own MessageStore/MessageRoomStore slice. -- **`MessageActionTouchable`** (`components/Touchable/MessageActionTouchable.tsx`) is a smaller building block (a `Pressable` wired to `useMessageLongPress`) reused by several leaf components (Reactions, Thread, Discussion, Broadcast, attachment sub-views) that need the row's long-press behavior without being the row's own top-level touchable. - ---- - -## Vocabulary alignment - -`TMessageActionState` (`definitions/IMessage.ts`) is `{ kind: 'edit' | 'quote' | 'react', ... } | null` — this matches CONTEXT.md's **Message Action** definition exactly (Quote, Edit, React; no "reply" action). `MessageActionStore`'s `action` field is the **Message Action State** as defined in the glossary; the retired term "interaction" does not appear anywhere in this module's stores or hooks. **Positional State** (highlight, jump/scroll position) is not owned here — it arrives as the plain `highlighted` prop threaded down from `RoomView`'s list. - -**Known follow-up:** `MessageRoomState`'s handler/constant fields are all individually optional (`?`), which is looser than the shape the values actually have once mounted (all frozen fields are supplied together). A future pass may flatten this to a non-optional shape post-mount; as of this refactor it remains all-optional. diff --git a/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx b/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx index a2373ece1c1..fc48df10e08 100644 --- a/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx +++ b/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx @@ -6,6 +6,8 @@ import { useMediaAutoDownload } from '../useMediaAutoDownload'; import { MessageProvider } from '~/containers/message/stores/MessageStore'; import { MessageRoomProvider, type MessageRoomState } from '~/containers/message/stores/MessageRoomStore'; import { mockedStore } from '~/reducers/mockedStore'; +import { setUser } from '~/actions/login'; +import { selectServerSuccess } from '~/actions/server'; import { type IAttachment, type IUserMessage, type TAnyMessageModel } from '~/definitions'; import { cancelDownload, downloadMediaFile, getMediaCache, isDownloadActive } from '~/lib/methods/handleMediaDownload'; import { fetchAutoDownloadEnabled } from '~/lib/methods/autoDownloadPreference'; @@ -59,6 +61,9 @@ const mockEmitterOn = emitter.on as jest.Mock; const URL = 'https://open.rocket.chat/file.png'; const USER = { id: 'user-1', username: 'john', token: 'token' }; +mockedStore.dispatch(setUser(USER)); +mockedStore.dispatch(selectServerSuccess({ server: 'https://open.rocket.chat', version: '', name: '' })); + // Flushes the mount effect's async cache/auto-download chain inside act, used when the // final status equals the initial 'to-download' so there is no change for waitFor to await. const flushMount = () => @@ -79,7 +84,7 @@ const renderMediaHook = ({ showAttachment?: (file: IAttachment) => void; ctx?: Partial; }) => { - const contextValue: Partial = { baseUrl: 'https://open.rocket.chat', user: USER, ...ctx }; + const contextValue: Partial = { ...ctx }; const item = { id: 'msg-1' } as unknown as TAnyMessageModel; const wrapper = ({ children }: { children: ReactNode }) => ( diff --git a/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx b/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx index 120f3bf6e33..4504dda8c35 100644 --- a/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx +++ b/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx @@ -123,8 +123,7 @@ describe('useMessageAccessibilityLabel', () => { expect( renderLabel(buildItem({ autoTranslate: true, translations: [{ _id: 't1', language: 'en', value: 'translated text' }] }), { autoTranslateRoom: true, - autoTranslateLanguage: 'en', - user: { username: 'bob' } + autoTranslateLanguage: 'en' }) ).toBe(`alice ${HOUR} Message translated into English.`); }); @@ -139,7 +138,7 @@ describe('useMessageAccessibilityLabel', () => { unread: true, attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }] }), - { isReadReceiptEnabled: true, autoTranslateRoom: true, autoTranslateLanguage: 'en', user: { username: 'bob' } } + { isReadReceiptEnabled: true, autoTranslateRoom: true, autoTranslateLanguage: 'en' } ) ).toBe(`alice ${HOUR} Message translated into English. Image description: A wavy pattern Message was not read`); }); diff --git a/app/containers/message/hooks/useMediaAutoDownload.tsx b/app/containers/message/hooks/useMediaAutoDownload.tsx index 85ef0f0bad6..d679646a4f7 100644 --- a/app/containers/message/hooks/useMediaAutoDownload.tsx +++ b/app/containers/message/hooks/useMediaAutoDownload.tsx @@ -79,15 +79,14 @@ export const useMediaAutoDownload = ({ const baseUrl = useBaseUrl(); const user = useMessageUser(); const [status, dispatchDownloadEvent] = useReducer(downloadStatusReducer, 'to-download'); - // Local overrides (downloaded uri, decrypted state) win over the file prop, which may still carry the remote url const [fileOverrides, setFileOverrides] = useState | null>(null); const currentFile = fileOverrides ? { ...file, ...fileOverrides } : file; const originalUrl = getOriginalURL(file); const url = formatAttachmentUrl( file.title_link || getFileProperty(currentFile, fileType, 'url'), - user?.id ?? '', - user?.token ?? '', - baseUrl ?? '', + user.id, + user.token, + baseUrl, originalUrl ); const isEncrypted = currentFile.e2e === 'pending'; @@ -127,7 +126,7 @@ export const useMediaAutoDownload = ({ }; const tryAutoDownload = async () => { - const isCurrentUserAuthor = author?._id === user?.id; + const isCurrentUserAuthor = author?._id === user.id; const isAutoDownloadEnabled = fetchAutoDownloadEnabled(`${fileType}PreferenceDownload`); if (isAutoDownloadEnabled || isCurrentUserAuthor) { await download(); @@ -138,7 +137,7 @@ export const useMediaAutoDownload = ({ try { dispatchDownloadEvent('download_started'); const uri = await downloadMediaFile({ - messageId: id ?? '', + messageId: id, downloadUrl: url, type: fileType, mimeType: getFileProperty(currentFile, fileType, 'type'), diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx index 9dd390f34b8..e3944ff2c41 100644 --- a/app/containers/message/index.tsx +++ b/app/containers/message/index.tsx @@ -3,33 +3,39 @@ import { memo } from 'react'; import MessageTouchable from './components/Touchable/MessageTouchable'; import { type TAnyMessageModel } from '~/definitions'; import MessageSeparator from '../Separator/MessageSeparator'; -import { MessageProvider } from './stores/MessageStore'; +import { MessageProvider, useMessageSeparators } from './stores/MessageStore'; interface IMessageContainerProps { item: TAnyMessageModel; previousItem?: TAnyMessageModel; + lastSeen?: Date | null; + withSeparators?: boolean; isIgnored?: boolean; highlighted?: boolean; onLongPress?: (item: TAnyMessageModel) => void; threadBadgeColor?: string; onPress?: () => void; isPreview?: boolean; - dateSeparator?: Date | string | null; - showUnreadSeparator?: boolean; } +const MessageSeparators = () => { + const { dateSeparator, showUnreadSeparator } = useMessageSeparators(); + return ; +}; + const MessageContainer = (props: IMessageContainerProps) => { - const { item, previousItem, dateSeparator, showUnreadSeparator } = props; + const { item, previousItem, lastSeen, withSeparators } = props; return ( - + {withSeparators ? : null} ); }; diff --git a/app/containers/message/stores/A11yGate.tsx b/app/containers/message/stores/A11yGate.tsx new file mode 100644 index 00000000000..49a08e023dc --- /dev/null +++ b/app/containers/message/stores/A11yGate.tsx @@ -0,0 +1,17 @@ +import { createContext, use, type ReactElement, type ReactNode } from 'react'; + +import { useIsAccessibilityNavigationEnabled } from '~/lib/hooks/useIsAccessibilityNavigationEnabled'; + +// One boolean per room: is accessibility navigation (screen reader OR external keyboard) active? +// Message rows read it to skip the react-native-a11y-order wrappers when nobody needs them. +// Defaults false so message leaves rendered outside a provider (stories, isolated tests) render +// gated-off — identical to how they render for a touch user with no assistive tech. +export const A11yGateContext = createContext(false); + +export const useA11yGate = (): boolean => use(A11yGateContext); + +export const A11yGateProvider = ({ children }: { children: ReactNode }): ReactElement => { + const enabled = useIsAccessibilityNavigationEnabled(); + + return {children}; +}; diff --git a/app/containers/message/stores/MessageActionStore.tsx b/app/containers/message/stores/MessageActionStore.tsx index 4d594ea88ed..65a14f5aefb 100644 --- a/app/containers/message/stores/MessageActionStore.tsx +++ b/app/containers/message/stores/MessageActionStore.tsx @@ -4,9 +4,8 @@ import { createStore, useStore } from 'zustand'; import { type TMessageActionState } from '~/definitions'; type TMessageActionActions = { - startEditing(messageId: string): void; - startQuote(messageId: string): void; - addQuote(messageId: string): void; + requestEditing(messageId: string): void; + requestQuote(messageId: string): void; removeQuote(messageId: string): void; startReacting(messageId: string): void; setQuoteMessageIds(messageIds: string[]): void; @@ -20,21 +19,26 @@ type MessageActionState = { }; export const createMessageActionStore = (initialAction?: TMessageActionState) => - createStore()(set => ({ + createStore()((set, get) => ({ action: initialAction ?? null, actions: { - startEditing: messageId => set({ action: { kind: 'edit', messageId } }), - startQuote: messageId => set({ action: { kind: 'quote', messageIds: [messageId] } }), - addQuote: messageId => - set(state => { - if (state.action?.kind !== 'quote') { - return { action: { kind: 'quote', messageIds: [messageId] } }; - } - if (state.action.messageIds.includes(messageId)) { - return {}; - } - return { action: { kind: 'quote', messageIds: [...state.action.messageIds, messageId] } }; - }), + requestEditing: messageId => { + if (get().action !== null) { + return; + } + set({ action: { kind: 'edit', messageId } }); + }, + requestQuote: messageId => { + const { action } = get(); + if (action === null) { + set({ action: { kind: 'quote', messageIds: [messageId] } }); + return; + } + if (action.kind !== 'quote' || action.messageIds.includes(messageId)) { + return; + } + set({ action: { kind: 'quote', messageIds: [...action.messageIds, messageId] } }); + }, removeQuote: messageId => set(state => { if (state.action?.kind !== 'quote') { @@ -53,44 +57,46 @@ export type TMessageActionStore = ReturnType; export const MessageActionStoreContext = createContext(null); -const noOpAction = (): never => { - throw new Error('MessageActionStore: no provider — actions unavailable'); -}; +const inertStore = createStore>()(() => ({ action: null })); -const NO_OP_ACTIONS: TMessageActionActions = { - startEditing: noOpAction, - startQuote: noOpAction, - addQuote: noOpAction, - removeQuote: noOpAction, - startReacting: noOpAction, - setQuoteMessageIds: noOpAction, - clear: noOpAction -}; - -// Rows rendered outside a RoomView (search, pinned) can never be in edit mode. -// `action` is fixed at null (no `set` param) and its actions always throw — a safe, inert fallback. -export const inertStore = createStore()(() => ({ action: null, actions: NO_OP_ACTIONS })); - -const useMessageActionStore = (selector: (state: MessageActionState) => T): T => { +export const useMessageActionStoreApi = (): TMessageActionStore => { const store = useContext(MessageActionStoreContext); if (!store) { throw new Error('Message action hooks must be used within a MessageActionProvider'); } - return useStore(store, selector); + return store; }; +const useMessageActionStore = (selector: (state: MessageActionState) => T): T => + useStore(useMessageActionStoreApi(), selector); + // `action` is a single ref replaced wholesale on every `set` — no useShallow needed. export const useMessageAction = (): TMessageActionState => useMessageActionStore(s => s.action); +export const useMessageActionKind = (): NonNullable['kind'] | null => + useMessageActionStore(s => s.action?.kind ?? null); + /** * Unlike `useMessageAction`, which throws without a provider, this hook degrades to `false` via * an inert store — search/pinned message rows render outside a `MessageActionProvider` and can never be editing. */ export const useIsBeingEdited = (messageId: string): boolean => { const store = useContext(MessageActionStoreContext) ?? inertStore; - return useStore(store, s => s.action?.kind === 'edit' && s.action.messageId === messageId); + return useStore( + store, + (s: Pick) => s.action?.kind === 'edit' && s.action.messageId === messageId + ); }; +// Stable ref so the selector doesn't emit a fresh array when not quoting. +const EMPTY_MESSAGE_IDS: string[] = []; + +export const useQuotedMessageIds = (): string[] => + useMessageActionStore(s => (s.action?.kind === 'quote' ? s.action.messageIds : EMPTY_MESSAGE_IDS)); + +export const useEditingMessageId = (): string | undefined => + useMessageActionStore(s => (s.action?.kind === 'edit' ? s.action.messageId : undefined)); + export const MessageActionProvider = ({ store: externalStore, initialAction, diff --git a/app/containers/message/stores/MessageRoomStore.tsx b/app/containers/message/stores/MessageRoomStore.tsx index 7368b8f1f55..0d7ff8a13bb 100644 --- a/app/containers/message/stores/MessageRoomStore.tsx +++ b/app/containers/message/stores/MessageRoomStore.tsx @@ -1,45 +1,38 @@ -import { createContext, useContext, useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'; import { createStore, useStore } from 'zustand'; import { useShallow } from 'zustand/react/shallow'; -import { type IAttachment, type IRoomInfoParam, type TAnyMessageModel } from '~/definitions'; +import { type IUseRoomMessageHandlersResult, type IUser, type TAnyMessageModel } from '~/definitions'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; +import { useLiveRef } from '~/lib/hooks/useLiveRef'; import { useSetting } from '~/lib/hooks/useSetting'; +import { getUserSelector } from '~/selectors/login'; -export type MessageRoomState = { - // stable handlers - navToRoomInfo?: (navParam: IRoomInfoParam) => void; - showAttachment?: (file: IAttachment) => void; - blockAction?: (params: { actionId: string; appId: string; value: string; blockId: string; rid: string; mid: string }) => void; - handleEnterCall?: () => void; - fetchThreadName?: (tmid: string, id: string) => Promise; - toggleFollowThread?: (isFollowingThread: boolean, tmid?: string) => Promise; +type LiveCallbacks = { jumpToMessage?: (link: string) => void; closeEmojiAndAction?: (action?: (params?: unknown) => void, params?: unknown) => void; - // row action handlers - onReactionPress?: (emoji: string, id: string) => void; - onReactionLongPress?: (item: TAnyMessageModel) => void; reactionInit?: (messageId: string) => void; - onDiscussionPress?: (drid: TAnyMessageModel['drid']) => void; - onThreadPress?: (item: TAnyMessageModel) => void; - replyBroadcast?: (item: TAnyMessageModel) => void; errorActionsShow?: (item: TAnyMessageModel) => void; - onAnswerButtonPress?: (message: string, tshow?: boolean) => void; - onEncryptedPress?: () => void; - archived?: boolean; - isReadReceiptEnabled?: boolean; - // room constants +}; + +export type MessageRoomState = LiveCallbacks & { + handlers?: Partial; rid?: string; - user?: { id?: string; username?: string; token?: string }; - baseUrl?: string; - broadcast?: boolean; isThreadRoom?: boolean; + tmid?: string; + archived?: boolean; + broadcast?: boolean; + isReadReceiptEnabled?: boolean; Message_GroupingPeriod?: number; timeFormat?: string; - // reactive tail (provider keeps current) autoTranslateRoom?: boolean; autoTranslateLanguage?: string; }; +type MessageRoomSnapshot = { [K in keyof Required]: MessageRoomState[K] }; + +type LiveCallbacksSnapshot = { [K in keyof Required]: LiveCallbacks[K] }; + export const createMessageRoomStore = (initial: MessageRoomState) => createStore(() => ({ ...initial })); export type MessageRoomStore = ReturnType; @@ -54,74 +47,60 @@ const useMessageRoomStore = (selector: (state: MessageRoomState) => T): T => return useStore(store, selector); }; -// Handlers and room constants captured once at mount by MessageRoomStoreProvider below (everything -// outside the reactive-tail resync effect). Callers MUST pass referentially stable values for these. -const FROZEN_KEYS = [ - 'navToRoomInfo', - 'showAttachment', - 'blockAction', - 'handleEnterCall', - 'fetchThreadName', - 'toggleFollowThread', - 'jumpToMessage', - 'closeEmojiAndAction', - 'onReactionPress', - 'onReactionLongPress', - 'reactionInit', - 'onDiscussionPress', - 'onThreadPress', - 'replyBroadcast', - 'errorActionsShow', - 'onAnswerButtonPress', - 'onEncryptedPress', - 'rid', - 'user', - 'baseUrl', - 'isThreadRoom' -] as const satisfies readonly (keyof MessageRoomState)[]; - -const useFrozenHandlersGuardProd = (_state: MessageRoomState): void => {}; - -// Warns once (after mount) if a frozen handler/constant's identity changes, since the provider -// only captures the initial value and never re-syncs it (see FROZEN_KEYS above). -const useFrozenHandlersGuardDev = (state: MessageRoomState): void => { - const initialRef = useRef(state); - const warnedRef = useRef(false); - useEffect(() => { - if (warnedRef.current) return; - const changed = FROZEN_KEYS.filter(key => !Object.is(initialRef.current[key], state[key])); - if (changed.length > 0) { - warnedRef.current = true; - console.warn( - `[MessageRoomStore] handler/constant identity changed after mount for: ${changed.join(', ')}. ` + - 'MessageRoomProvider captures these once; pass referentially stable values.' - ); - } - }); +const useLiveCallbacks = (callbacks: LiveCallbacks): LiveCallbacksSnapshot => { + const latest = useLiveRef(callbacks); + const [wrappers] = useState>(() => ({ + jumpToMessage: (...args) => latest.current.jumpToMessage?.(...args), + closeEmojiAndAction: (...args) => latest.current.closeEmojiAndAction?.(...args), + reactionInit: (...args) => latest.current.reactionInit?.(...args), + errorActionsShow: (...args) => latest.current.errorActionsShow?.(...args) + })); + + const hasJumpToMessage = !!callbacks.jumpToMessage; + const hasCloseEmojiAndAction = !!callbacks.closeEmojiAndAction; + const hasReactionInit = !!callbacks.reactionInit; + const hasErrorActionsShow = !!callbacks.errorActionsShow; + + return useMemo( + () => ({ + jumpToMessage: hasJumpToMessage ? wrappers.jumpToMessage : undefined, + closeEmojiAndAction: hasCloseEmojiAndAction ? wrappers.closeEmojiAndAction : undefined, + reactionInit: hasReactionInit ? wrappers.reactionInit : undefined, + errorActionsShow: hasErrorActionsShow ? wrappers.errorActionsShow : undefined + }), + [wrappers, hasJumpToMessage, hasCloseEmojiAndAction, hasReactionInit, hasErrorActionsShow] + ); }; -const useFrozenHandlersGuard: (state: MessageRoomState) => void = __DEV__ - ? useFrozenHandlersGuardDev - : useFrozenHandlersGuardProd; - -const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { - const [store] = useState(() => createMessageRoomStore(state)); - useFrozenHandlersGuard(state); +export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { + const timeFormatSetting = useSetting('Message_TimeFormat') as string; + const timeFormat = state.timeFormat ?? timeFormatSetting; + const callbacks = useLiveCallbacks(state); + const [store] = useState(() => createMessageRoomStore({ ...state, ...callbacks, timeFormat })); - // These fields can change mid-session (e.g. an open room gets archived), unlike the - // constants/handlers captured once above. The dep array keeps store writes on-change only. useEffect(() => { - store.setState({ - timeFormat: state.timeFormat, + const snapshot: MessageRoomSnapshot = { + ...callbacks, + handlers: state.handlers, + rid: state.rid, + isThreadRoom: state.isThreadRoom, + tmid: state.tmid, + timeFormat, autoTranslateRoom: state.autoTranslateRoom, autoTranslateLanguage: state.autoTranslateLanguage, archived: state.archived, broadcast: state.broadcast, isReadReceiptEnabled: state.isReadReceiptEnabled, Message_GroupingPeriod: state.Message_GroupingPeriod - }); + }; + store.setState(snapshot); }, [ - state.timeFormat, + callbacks, + state.handlers, + state.rid, + state.isThreadRoom, + state.tmid, + timeFormat, state.autoTranslateRoom, state.autoTranslateLanguage, state.archived, @@ -134,51 +113,44 @@ const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode return {children}; }; -const MessageRoomProviderWithSetting = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { - const Message_TimeFormat = useSetting('Message_TimeFormat') as string; - - return ( - - {children} - - ); -}; - -export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { - return state.timeFormat != null ? ( - {children} - ) : ( - {children} - ); -}; - -export const useNavToRoomInfo = (): ((navParam: IRoomInfoParam) => void) | undefined => useMessageRoomStore(s => s.navToRoomInfo); -export const useShowAttachment = (): ((file: IAttachment) => void) | undefined => useMessageRoomStore(s => s.showAttachment); -export const useBlockAction = (): MessageRoomState['blockAction'] => useMessageRoomStore(s => s.blockAction); -export const useHandleEnterCall = (): (() => void) | undefined => useMessageRoomStore(s => s.handleEnterCall); -export const useFetchThreadName = (): MessageRoomState['fetchThreadName'] => useMessageRoomStore(s => s.fetchThreadName); -export const useToggleFollowThread = (): MessageRoomState['toggleFollowThread'] => useMessageRoomStore(s => s.toggleFollowThread); export const useJumpToMessage = (): ((link: string) => void) | undefined => useMessageRoomStore(s => s.jumpToMessage); export const useCloseEmojiAndAction = (): MessageRoomState['closeEmojiAndAction'] => useMessageRoomStore(s => s.closeEmojiAndAction); -export const useOnReactionPress = (): MessageRoomState['onReactionPress'] => useMessageRoomStore(s => s.onReactionPress); -export const useOnReactionLongPress = (): MessageRoomState['onReactionLongPress'] => - useMessageRoomStore(s => s.onReactionLongPress); export const useReactionInit = (): MessageRoomState['reactionInit'] => useMessageRoomStore(s => s.reactionInit); -export const useOnDiscussionPress = (): MessageRoomState['onDiscussionPress'] => useMessageRoomStore(s => s.onDiscussionPress); -export const useOnThreadPress = (): MessageRoomState['onThreadPress'] => useMessageRoomStore(s => s.onThreadPress); -export const useReplyBroadcast = (): MessageRoomState['replyBroadcast'] => useMessageRoomStore(s => s.replyBroadcast); export const useErrorActionsShow = (): MessageRoomState['errorActionsShow'] => useMessageRoomStore(s => s.errorActionsShow); -export const useOnAnswerButtonPress = (): MessageRoomState['onAnswerButtonPress'] => - useMessageRoomStore(s => s.onAnswerButtonPress); -export const useOnEncryptedPress = (): MessageRoomState['onEncryptedPress'] => useMessageRoomStore(s => s.onEncryptedPress); +export const useNavToRoomInfo = (): IUseRoomMessageHandlersResult['navToRoomInfo'] | undefined => + useMessageRoomStore(s => s.handlers?.navToRoomInfo); +export const useShowAttachment = (): IUseRoomMessageHandlersResult['showAttachment'] | undefined => + useMessageRoomStore(s => s.handlers?.showAttachment); +export const useBlockAction = (): IUseRoomMessageHandlersResult['blockAction'] | undefined => + useMessageRoomStore(s => s.handlers?.blockAction); +export const useHandleEnterCall = (): IUseRoomMessageHandlersResult['handleEnterCall'] | undefined => + useMessageRoomStore(s => s.handlers?.handleEnterCall); +export const useOnDiscussionPress = (): IUseRoomMessageHandlersResult['onDiscussionPress'] | undefined => + useMessageRoomStore(s => s.handlers?.onDiscussionPress); +export const useOnThreadPress = (): IUseRoomMessageHandlersResult['onThreadPress'] | undefined => + useMessageRoomStore(s => s.handlers?.onThreadPress); +export const useOnEncryptedPress = (): IUseRoomMessageHandlersResult['onEncryptedPress'] | undefined => + useMessageRoomStore(s => s.handlers?.onEncryptedPress); +export const useOnReactionPress = (): IUseRoomMessageHandlersResult['onReactionPress'] | undefined => + useMessageRoomStore(s => s.handlers?.onReactionPress); +export const useOnReactionLongPress = (): IUseRoomMessageHandlersResult['onReactionLongPress'] | undefined => + useMessageRoomStore(s => s.handlers?.onReactionLongPress); +export const useReplyBroadcast = (): IUseRoomMessageHandlersResult['replyBroadcast'] | undefined => + useMessageRoomStore(s => s.handlers?.replyBroadcast); +export const useFetchThreadName = (): IUseRoomMessageHandlersResult['fetchThreadName'] | undefined => + useMessageRoomStore(s => s.handlers?.fetchThreadName); +export const useToggleFollowThread = (): IUseRoomMessageHandlersResult['toggleFollowThread'] | undefined => + useMessageRoomStore(s => s.handlers?.toggleFollowThread); +export const useOnAnswerButtonPress = (): IUseRoomMessageHandlersResult['onAnswerButtonPress'] | undefined => + useMessageRoomStore(s => s.handlers?.onAnswerButtonPress); export const useIsArchived = (): boolean | undefined => useMessageRoomStore(s => s.archived); export const useIsReadReceiptEnabled = (): boolean | undefined => useMessageRoomStore(s => s.isReadReceiptEnabled); export const useRid = (): string | undefined => useMessageRoomStore(s => s.rid); -export const useMessageUser = (): MessageRoomState['user'] => useMessageRoomStore(s => s.user); -export const useBaseUrl = (): string | undefined => useMessageRoomStore(s => s.baseUrl); +export const useMessageUser = (): IUser => useAppSelector(getUserSelector); +export const useBaseUrl = (): string => useAppSelector(state => state.server.server); export const useBroadcast = (): boolean | undefined => useMessageRoomStore(s => s.broadcast); export const useTimeFormat = (): string | undefined => useMessageRoomStore(s => s.timeFormat); export const useIsThreadRoom = (): boolean | undefined => useMessageRoomStore(s => s.isThreadRoom); diff --git a/app/containers/message/stores/MessageStore.tsx b/app/containers/message/stores/MessageStore.tsx index 26ee015a9dd..e19697bdbb1 100644 --- a/app/containers/message/stores/MessageStore.tsx +++ b/app/containers/message/stores/MessageStore.tsx @@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow'; import { Keyboard } from 'react-native'; import { type IAttachment, type TAnyMessageModel } from '~/definitions'; -import { getMessageTranslation } from '../utils'; +import { getMessageSeparators, getMessageTranslation, type TMessageSeparators } from '../utils'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '~/lib/constants/keys'; import { messagesStatus } from '~/lib/constants/messagesStatus'; import { useDebounce } from '~/lib/methods/helpers/debounce'; @@ -19,14 +19,15 @@ import { useJumpToMessage, useMessageGroupingPeriod, useMessageUser, - useOnDiscussionPress, - useOnThreadPress + useOnThreadPress, + useOnDiscussionPress } from './MessageRoomStore'; type MessageStoreState = { tick: number; item: TAnyMessageModel; previousItem?: TAnyMessageModel; + lastSeen: Date | null; isIgnored: boolean; manualUnignored: boolean; reveal: () => void; @@ -36,7 +37,10 @@ type MessageStoreState = { }; const createMessageStore = ( - initial: Pick + initial: Pick< + MessageStoreState, + 'item' | 'previousItem' | 'lastSeen' | 'isIgnored' | 'onPress' | 'onLongPress' | 'threadBadgeColor' + > ) => createStore(set => ({ tick: 0, @@ -119,6 +123,7 @@ const subscribeModel = (m: TAnyMessageModel, store: MessageStore) => { export const MessageProvider = ({ item, previousItem, + lastSeen = null, onPress, onLongPress, threadBadgeColor, @@ -127,6 +132,7 @@ export const MessageProvider = ({ }: { item: TAnyMessageModel; previousItem?: TAnyMessageModel; + lastSeen?: Date | null; onPress?: () => void; onLongPress?: (item: TAnyMessageModel) => void; threadBadgeColor?: string; @@ -134,7 +140,7 @@ export const MessageProvider = ({ children: ReactNode; }): ReactElement => { const [store] = useState(() => - createMessageStore({ item, previousItem, isIgnored: isIgnored ?? false, onPress, onLongPress, threadBadgeColor }) + createMessageStore({ item, previousItem, lastSeen, isIgnored: isIgnored ?? false, onPress, onLongPress, threadBadgeColor }) ); // Push item/previousItem into the store and (re)subscribe both records to the same tick. @@ -149,6 +155,10 @@ export const MessageProvider = ({ }; }, [item, previousItem, store]); + useEffect(() => { + store.setState({ lastSeen }); + }, [lastSeen, store]); + // Mirror per-message row handlers so field-level selectors subscribe without churning the context value. useEffect(() => { store.setState({ onPress, onLongPress, threadBadgeColor }); @@ -213,6 +223,9 @@ export const useAttachments = (): TAnyMessageModel['attachments'] => useMessageF export const useMessageHeaderMeta = (): Pick => useMessageStore(useShallow(s => ({ ts: s.item.ts, unread: s.item.unread, pinned: s.item.pinned, t: s.item.t }))); +export const useMessageSeparators = (): TMessageSeparators => + useMessageStore(useShallow(s => getMessageSeparators(s.previousItem, s.item, s.lastSeen))); + const computeIsHeader = ( prev: TAnyMessageModel | undefined, item: TAnyMessageModel, @@ -291,14 +304,20 @@ export const useMessageId = (): TAnyMessageModel['id'] => useMessageField(item = export const useReplies = (): TAnyMessageModel['replies'] => useMessageField(item => item.replies); +const autoTranslateLanguageFor = ( + item: TAnyMessageModel, + username: string | undefined, + autoTranslateRoom: boolean | undefined, + autoTranslateLanguage: string | undefined +): string | undefined => + autoTranslateRoom && autoTranslateLanguage && item.autoTranslate && item.u?.username !== username + ? autoTranslateLanguage + : undefined; + export const useTranslateLanguage = (): string | undefined => { const { autoTranslateRoom, autoTranslateLanguage } = useAutoTranslate(); const user = useMessageUser(); - return useMessageStore(s => { - const otherUserMessage = s.item.u?.username !== user?.username; - const canTranslate = autoTranslateRoom && autoTranslateLanguage && s.item.autoTranslate && otherUserMessage; - return canTranslate ? autoTranslateLanguage : undefined; - }); + return useMessageStore(s => autoTranslateLanguageFor(s.item, user?.username, autoTranslateRoom, autoTranslateLanguage)); }; export const useMessageText = (): { messageText: TAnyMessageModel['msg']; isTranslated: boolean } => { @@ -308,9 +327,9 @@ export const useMessageText = (): { messageText: TAnyMessageModel['msg']; isTran useShallow(s => { let messageText = s.item.msg; let isTranslated = false; - const otherUserMessage = s.item.u?.username !== user?.username; - if (autoTranslateRoom && s.item.autoTranslate && autoTranslateLanguage && otherUserMessage) { - const translated = getMessageTranslation(s.item, autoTranslateLanguage); + const language = autoTranslateLanguageFor(s.item, user?.username, autoTranslateRoom, autoTranslateLanguage); + if (language) { + const translated = getMessageTranslation(s.item, language); isTranslated = !!translated; messageText = translated || messageText; } diff --git a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx index a342618f4a4..0a76658ed5d 100644 --- a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx +++ b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx @@ -2,10 +2,11 @@ import { render } from '@testing-library/react-native'; import { createMessageActionStore, - inertStore, MessageActionProvider, + useEditingMessageId, useIsBeingEdited, - useMessageAction + useMessageAction, + useQuotedMessageIds } from '../MessageActionStore'; describe('MessageActionStore', () => { @@ -21,7 +22,7 @@ describe('MessageActionStore', () => { expect(spy).toHaveBeenLastCalledWith(false); }); - it('does not share mutable state across separate no-provider renders, and its actions throw', () => { + it('does not share mutable state across separate no-provider renders', () => { const spyA = jest.fn(); const spyB = jest.fn(); const ProbeA = () => { @@ -38,9 +39,6 @@ describe('MessageActionStore', () => { expect(spyA).toHaveBeenLastCalledWith(false); expect(spyB).toHaveBeenLastCalledWith(false); - expect(() => inertStore.getState().actions.startEditing('x')).toThrow( - 'MessageActionStore: no provider — actions unavailable' - ); }); it('returns true only for the message being edited under a provider', () => { @@ -95,6 +93,74 @@ describe('MessageActionStore', () => { }); }); + describe('useQuotedMessageIds', () => { + it('returns the quoted messageIds under a quote action', () => { + const spy = jest.fn(); + const Probe = () => { + spy(useQuotedMessageIds()); + return null; + }; + + render( + + + + ); + + expect(spy).toHaveBeenLastCalledWith(['msg-1', 'msg-2']); + }); + + it('returns an empty array for non-quote actions', () => { + const spy = jest.fn(); + const Probe = () => { + spy(useQuotedMessageIds()); + return null; + }; + + render( + + + + ); + + expect(spy).toHaveBeenLastCalledWith([]); + }); + }); + + describe('useEditingMessageId', () => { + it('returns the messageId under an edit action', () => { + const spy = jest.fn(); + const Probe = () => { + spy(useEditingMessageId()); + return null; + }; + + render( + + + + ); + + expect(spy).toHaveBeenLastCalledWith('msg-1'); + }); + + it('returns undefined for non-edit actions', () => { + const spy = jest.fn(); + const Probe = () => { + spy(useEditingMessageId()); + return null; + }; + + render( + + + + ); + + expect(spy).toHaveBeenLastCalledWith(undefined); + }); + }); + describe('action hooks without a provider', () => { let consoleErrorSpy: jest.SpyInstance; @@ -181,63 +247,75 @@ describe('MessageActionStore', () => { }); }); - describe('action creators (union transitions)', () => { - it('startEditing sets an edit action with the given messageId', () => { + describe('requestEditing', () => { + it('admits and sets an edit action when idle', () => { const store = createMessageActionStore(); - store.getState().actions.startEditing('msg-1'); + store.getState().actions.requestEditing('msg-1'); expect(store.getState().action).toEqual({ kind: 'edit', messageId: 'msg-1' }); }); - it('startQuote starts a quote action with a single messageId', () => { + it('rejects when an action is already in progress', () => { + const store = createMessageActionStore({ kind: 'quote', messageIds: ['other-msg'] }); + store.getState().actions.requestEditing('msg-1'); + expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['other-msg'] }); + }); + }); + + describe('requestQuote', () => { + it('admits and starts a quote action with a single messageId when idle', () => { const store = createMessageActionStore(); - store.getState().actions.startQuote('msg-1'); + store.getState().actions.requestQuote('msg-1'); expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-1'] }); }); - it('addQuote appends to an existing quote action', () => { - const store = createMessageActionStore(); - store.getState().actions.startQuote('msg-1'); - store.getState().actions.addQuote('msg-2'); + it('admits and appends to an existing quote action', () => { + const store = createMessageActionStore({ kind: 'quote', messageIds: ['msg-1'] }); + store.getState().actions.requestQuote('msg-2'); expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] }); }); - it('addQuote starts a new quote action when there is none', () => { - const store = createMessageActionStore(); - store.getState().actions.addQuote('msg-1'); + it('rejects a duplicate messageId', () => { + const store = createMessageActionStore({ kind: 'quote', messageIds: ['msg-1'] }); + store.getState().actions.requestQuote('msg-1'); expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-1'] }); }); - it('addQuote does not append a duplicate messageId', () => { - const store = createMessageActionStore(); - store.getState().actions.startQuote('msg-1'); - store.getState().actions.addQuote('msg-1'); - expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-1'] }); + it('rejects when a non-quote action is already in progress', () => { + const store = createMessageActionStore({ kind: 'edit', messageId: 'other-msg' }); + store.getState().actions.requestQuote('msg-1'); + expect(store.getState().action).toEqual({ kind: 'edit', messageId: 'other-msg' }); }); + }); - it('removeQuote drops a messageId and keeps the quote action if others remain', () => { + describe('removeQuote', () => { + it('drops a messageId and keeps the quote action if others remain', () => { const store = createMessageActionStore({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] }); store.getState().actions.removeQuote('msg-1'); expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-2'] }); }); - it('removeQuote clears the action when the last quoted message is removed', () => { + it('clears the action when the last quoted message is removed', () => { const store = createMessageActionStore({ kind: 'quote', messageIds: ['msg-1'] }); store.getState().actions.removeQuote('msg-1'); expect(store.getState().action).toBeNull(); }); - it('removeQuote is a no-op when the current action is not a quote', () => { + it('is a no-op when the current action is not a quote', () => { const store = createMessageActionStore({ kind: 'edit', messageId: 'msg-1' }); store.getState().actions.removeQuote('msg-1'); expect(store.getState().action).toEqual({ kind: 'edit', messageId: 'msg-1' }); }); + }); - it('startReacting sets a react action with the given messageId', () => { + describe('startReacting', () => { + it('sets a react action with the given messageId', () => { const store = createMessageActionStore(); store.getState().actions.startReacting('msg-1'); expect(store.getState().action).toEqual({ kind: 'react', messageId: 'msg-1' }); }); + }); + describe('setQuoteMessageIds and clear', () => { it('setQuoteMessageIds sets a quote action with the given ids, or clears it when empty', () => { const store = createMessageActionStore(); store.getState().actions.setQuoteMessageIds(['msg-1', 'msg-2']); @@ -247,6 +325,12 @@ describe('MessageActionStore', () => { expect(store.getState().action).toBeNull(); }); + it('setQuoteMessageIds overrides an in-progress non-quote action unconditionally', () => { + const store = createMessageActionStore({ kind: 'edit', messageId: 'msg-1' }); + store.getState().actions.setQuoteMessageIds(['msg-2']); + expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-2'] }); + }); + it('clear resets a react action', () => { const store = createMessageActionStore({ kind: 'react', messageId: 'msg-1' }); store.getState().actions.clear(); diff --git a/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx b/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx index 595036e41d0..9369d19cab7 100644 --- a/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx +++ b/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx @@ -1,9 +1,19 @@ import { memo } from 'react'; -import { act, render } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { Text } from 'react-native'; import { Provider } from 'react-redux'; -import { MessageRoomProvider, useAutoTranslate, useIsArchived, useTimeFormat } from '../MessageRoomStore'; +import { + MessageRoomProvider, + type MessageRoomState, + useAutoTranslate, + useBlockAction, + useIsArchived, + useNavToRoomInfo, + useReactionInit, + useShowAttachment, + useTimeFormat +} from '../MessageRoomStore'; import { mockedStore } from '~/reducers/mockedStore'; import { updateSettings } from '~/actions/settings'; @@ -20,24 +30,24 @@ describe('MessageRoomStore', () => { }); it('useIsArchived throws', () => { - const Probe = () => { + const Consumer = () => { useIsArchived(); return null; }; - expect(() => render()).toThrow('Message room hooks must be used within a MessageRoomProvider'); + expect(() => render()).toThrow('Message room hooks must be used within a MessageRoomProvider'); }); }); it('mirrors updated provider props into the store after mount', () => { const spy = jest.fn(); - const Probe = () => { + const Consumer = () => { spy(useTimeFormat()); return null; }; const wrap = (timeFormat: string) => ( - + ); @@ -51,7 +61,7 @@ describe('MessageRoomStore', () => { it('does not subscribe to Message_TimeFormat when a timeFormat prop is passed', () => { const spy = jest.fn(); - const Probe = () => { + const Consumer = () => { spy(useTimeFormat()); return null; }; @@ -59,7 +69,7 @@ describe('MessageRoomStore', () => { render( - + ); @@ -78,7 +88,7 @@ describe('MessageRoomStore', () => { mockedStore.dispatch(updateSettings('Message_TimeFormat', 'MMM Do YYYY')); const spy = jest.fn(); - const Probe = () => { + const Consumer = () => { spy(useTimeFormat()); return null; }; @@ -86,7 +96,7 @@ describe('MessageRoomStore', () => { render( - + ); @@ -104,11 +114,11 @@ describe('MessageRoomStore', () => { const autoTranslateSpy = jest.fn(); // memoized so re-renders only come from the store notifying its own subscription, // not from the parent tree re-rendering with a new element identity - const TimeFormatProbe = memo(() => { + const TimeFormatConsumer = memo(() => { timeFormatSpy(useTimeFormat()); return null; }); - const AutoTranslateProbe = memo(() => { + const AutoTranslateConsumer = memo(() => { autoTranslateSpy(useAutoTranslate()); return null; }); @@ -116,8 +126,8 @@ describe('MessageRoomStore', () => { const wrap = (autoTranslateRoom: boolean) => ( - - + + ); @@ -134,14 +144,14 @@ describe('MessageRoomStore', () => { it('resyncs archived when a room gets archived mid-session', () => { const spy = jest.fn(); - const Probe = () => { + const Consumer = () => { spy(useIsArchived()); return null; }; const wrap = (archived: boolean) => ( - + ); @@ -153,48 +163,130 @@ describe('MessageRoomStore', () => { expect(spy).toHaveBeenLastCalledWith(true); }); - describe('frozen handler guard (dev)', () => { - let warnSpy: jest.SpyInstance; + describe('handlers bag', () => { + it('reads a handler through the fine-grained selector on the first render', () => { + const blockAction = jest.fn(); + const spy = jest.fn(); + const Consumer = () => { + spy(useBlockAction()); + return null; + }; - beforeEach(() => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); + render( + + + + + + ); - afterEach(() => { - warnSpy.mockRestore(); + expect(spy).toHaveBeenNthCalledWith(1, blockAction); }); - it('warns once when a frozen handler identity changes after mount', () => { - const wrap = (navToRoomInfo: () => void) => ( + it('exposes navToRoomInfo/showAttachment from the handler bag', () => { + const bagNav = jest.fn(); + const bagShow = jest.fn(); + const navSpy = jest.fn(); + const showSpy = jest.fn(); + const Consumer = () => { + navSpy(useNavToRoomInfo()); + showSpy(useShowAttachment()); + return null; + }; + + render( - - probe + + ); - const { rerender } = render(wrap(() => {})); - expect(warnSpy).not.toHaveBeenCalled(); - - act(() => rerender(wrap(() => {}))); - - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('navToRoomInfo')); + expect(navSpy).toHaveBeenLastCalledWith(bagNav); + expect(showSpy).toHaveBeenLastCalledWith(bagShow); }); - it('does not warn when the same handler reference is passed across re-renders', () => { - const navToRoomInfo = () => {}; - const wrap = () => ( + it('resyncs the bag when the provider receives a new handlers prop', () => { + const first = jest.fn(); + const second = jest.fn(); + const spy = jest.fn(); + const Consumer = () => { + spy(useBlockAction()); + return null; + }; + const tree = (blockAction: jest.Mock) => ( - - probe + + ); + const { rerender } = render(tree(first)); + + expect(spy).toHaveBeenLastCalledWith(first); + + rerender(tree(second)); + + expect(spy).toHaveBeenLastCalledWith(second); + }); + }); + + describe('live callbacks', () => { + const wrap = (reactionInit?: MessageRoomState['reactionInit']) => ( + + + + + + ); + + const renderSpy = jest.fn(); + const ReactionInitConsumer = memo(() => { + const reactionInit = useReactionInit(); + renderSpy(reactionInit); + return reactionInit?.('message-id')}>reaction; + }); + + beforeEach(() => renderSpy.mockClear()); + + it('invokes the latest callback after a rerender', () => { + const first = jest.fn(); + const second = jest.fn(); + + const { rerender } = render(wrap(first)); + act(() => rerender(wrap(second))); + + fireEvent.press(screen.getByText('reaction')); + + expect(second).toHaveBeenCalledWith('message-id'); + expect(first).not.toHaveBeenCalled(); + }); + + it('keeps the callback identity stable across rerenders', () => { + const { rerender } = render(wrap(jest.fn())); + const renderCallsBefore = renderSpy.mock.calls.length; + + act(() => rerender(wrap(jest.fn()))); + + expect(renderSpy.mock.calls.length).toBe(renderCallsBefore); + }); + + it('stays undefined when the provider does not supply the callback', () => { + render(wrap()); + + expect(renderSpy).toHaveBeenLastCalledWith(undefined); + }); + + it('becomes defined when the provider starts supplying the callback', () => { + const reactionInit = jest.fn(); const { rerender } = render(wrap()); - act(() => rerender(wrap())); + expect(renderSpy).toHaveBeenLastCalledWith(undefined); + + act(() => rerender(wrap(reactionInit))); + fireEvent.press(screen.getByText('reaction')); - expect(warnSpy).not.toHaveBeenCalled(); + expect(reactionInit).toHaveBeenCalledWith('message-id'); }); }); }); diff --git a/app/containers/message/stores/__tests__/MessageStore.test.tsx b/app/containers/message/stores/__tests__/MessageStore.test.tsx index c995b9f85eb..d6fe5bf2f94 100644 --- a/app/containers/message/stores/__tests__/MessageStore.test.tsx +++ b/app/containers/message/stores/__tests__/MessageStore.test.tsx @@ -26,6 +26,7 @@ import { useMessageItem, useMessageLongPress, useMessagePress, + useMessageSeparators, useMessageStatus, useMessageText, useMessageTouchable, @@ -138,7 +139,11 @@ const MsgValueReporter = ({ spy }: { spy: jest.Mock }) => { const renderDerived = ( item: TAnyMessageModel, useHook: () => unknown, - { previousItem, config }: { previousItem?: TAnyMessageModel; config?: Partial } = {} + { + previousItem, + lastSeen, + config + }: { previousItem?: TAnyMessageModel; lastSeen?: Date | null; config?: Partial } = {} ) => { const spy = jest.fn(); const Probe = () => { @@ -148,7 +153,7 @@ const renderDerived = ( render( - + @@ -598,7 +603,7 @@ describe('MessageStore', () => { u: { _id: 'u2', username: 'bob' } }); const { latest } = renderDerived(model, useMessageText, { - config: { autoTranslateRoom: true, autoTranslateLanguage: 'pt-BR', user: { username: 'alice' } } + config: { autoTranslateRoom: true, autoTranslateLanguage: 'pt-BR' } }); expect(latest()).toEqual({ messageText: 'Olá mundo', isTranslated: true }); }); @@ -609,6 +614,95 @@ describe('MessageStore', () => { const { latest } = renderDerived(model, useMessageHeaderMeta); expect(latest()).toEqual({ ts: model.ts, unread: model.unread, pinned: model.pinned, t: model.t }); }); + + it('useMessageSeparators returns a date separator and no unread separator for the first message with no lastSeen', () => { + const ts = new Date('2024-01-01T10:00:00Z'); + const model = buildFakeModel({ ts }); + const { latest } = renderDerived(model, useMessageSeparators); + expect(latest()).toEqual({ dateSeparator: ts, showUnreadSeparator: false }); + }); + + it('useMessageSeparators returns no date separator when prev is on the same day', () => { + const ts = new Date('2024-01-01T10:00:00Z'); + const prevTs = new Date('2024-01-01T09:00:00Z'); + const model = buildFakeModel({ ts }); + const { latest } = renderDerived(model, useMessageSeparators, { previousItem: buildFakeModel({ id: 'p0', ts: prevTs }) }); + expect(latest()).toEqual({ dateSeparator: null, showUnreadSeparator: false }); + }); + + it('useMessageSeparators shows the unread separator on the first message at or after lastSeen', () => { + const ts = new Date('2024-01-01T10:00:00Z'); + const prevTs = new Date('2024-01-01T09:00:00Z'); + const lastSeen = new Date('2024-01-01T09:30:00Z'); + const model = buildFakeModel({ ts }); + const { latest } = renderDerived(model, useMessageSeparators, { + previousItem: buildFakeModel({ id: 'p0', ts: prevTs }), + lastSeen + }); + expect(latest()).toEqual({ dateSeparator: null, showUnreadSeparator: true }); + }); + + it('useMessageSeparators re-derives when lastSeen changes after the record mounts', () => { + const ts = new Date('2024-01-01T10:00:00Z'); + const prevTs = new Date('2024-01-01T09:00:00Z'); + const model = buildFakeModel({ ts }); + const previousItem = buildFakeModel({ id: 'p0', ts: prevTs }); + const spy = jest.fn(); + const Probe = () => { + spy(useMessageSeparators()); + return null; + }; + const wrap = (lastSeen: Date | null) => ( + + + + + + + + ); + const latest = () => { + const { calls } = spy.mock; + return calls[calls.length - 1]?.[0]; + }; + + const { rerender } = render(wrap(null)); + expect(latest()).toEqual({ dateSeparator: null, showUnreadSeparator: false }); + + act(() => rerender(wrap(new Date('2024-01-01T09:30:00Z')))); + expect(latest()).toEqual({ dateSeparator: null, showUnreadSeparator: true }); + }); + + it('useMessageSeparators re-derives when ts is mutated in place across a day boundary', () => { + const previousItem = buildFakeModel({ id: 'p0', ts: new Date('2024-01-01T23:59:00Z') }); + const model = buildFakeModel({ ts: new Date('2024-01-01T23:59:30Z') }); + const { latest } = renderDerived(model, useMessageSeparators, { previousItem }); + + expect(latest()).toEqual({ dateSeparator: null, showUnreadSeparator: false }); + + const confirmedTs = new Date('2024-01-02T00:00:10Z'); + act(() => { + (model as any).ts = confirmedTs; + (model as FakeModel)._emit(); + }); + + expect(latest()).toEqual({ dateSeparator: confirmedTs, showUnreadSeparator: false }); + }); + + it('useMessageSeparators re-derives when the previous message ts is mutated in place', () => { + const previousItem = buildFakeModel({ id: 'p0', ts: new Date('2024-01-01T23:59:00Z') }); + const model = buildFakeModel({ ts: new Date('2024-01-02T00:00:10Z') }); + const { latest } = renderDerived(model, useMessageSeparators, { previousItem }); + + expect(latest()).toEqual({ dateSeparator: model.ts, showUnreadSeparator: false }); + + act(() => { + (previousItem as any).ts = new Date('2024-01-02T00:00:00Z'); + (previousItem as FakeModel)._emit(); + }); + + expect(latest()).toEqual({ dateSeparator: null, showUnreadSeparator: false }); + }); }); describe('outside a MessageProvider', () => { @@ -891,7 +985,7 @@ describe('MessageStore', () => { it('reveals an ignored message instead of pressing', () => { const model = buildFakeModel({ tmid: 't1' }); const onThreadPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { isIgnored: true, config: { onThreadPress } }); + const { result } = renderMessageHook(useMessagePress, model, { isIgnored: true, config: { handlers: { onThreadPress } } }); act(() => result.current()); @@ -904,7 +998,10 @@ describe('MessageStore', () => { const onPress = jest.fn(); const onThreadPress = jest.fn(); const onDiscussionPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { onPress, config: { onThreadPress, onDiscussionPress } }); + const { result } = renderMessageHook(useMessagePress, model, { + onPress, + config: { handlers: { onThreadPress, onDiscussionPress } } + }); act(() => result.current()); @@ -917,7 +1014,7 @@ describe('MessageStore', () => { it('dismisses the keyboard and routes to thread press for a threaded reply', () => { const model = buildFakeModel({ tmid: 't1' }); const onThreadPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress } }); + const { result } = renderMessageHook(useMessagePress, model, { config: { handlers: { onThreadPress } } }); act(() => result.current()); @@ -928,7 +1025,7 @@ describe('MessageStore', () => { it('routes to thread press for a root message that has replies (tlm)', () => { const model = buildFakeModel({ tlm: new Date() }); const onThreadPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress } }); + const { result } = renderMessageHook(useMessagePress, model, { config: { handlers: { onThreadPress } } }); act(() => result.current()); @@ -938,7 +1035,9 @@ describe('MessageStore', () => { it('does not route to thread press while already inside the thread room', () => { const model = buildFakeModel({ tmid: 't1' }); const onThreadPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress, isThreadRoom: true } }); + const { result } = renderMessageHook(useMessagePress, model, { + config: { isThreadRoom: true, handlers: { onThreadPress } } + }); act(() => result.current()); @@ -948,7 +1047,7 @@ describe('MessageStore', () => { it('routes to discussion press when the message has a discussion', () => { const model = buildFakeModel({ dlm: new Date(), drid: 'drid-1' }); const onDiscussionPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { config: { onDiscussionPress } }); + const { result } = renderMessageHook(useMessagePress, model, { config: { handlers: { onDiscussionPress } } }); act(() => result.current()); @@ -958,7 +1057,7 @@ describe('MessageStore', () => { it('debounces on the leading edge, suppressing an immediate repeat until the window elapses', () => { const model = buildFakeModel({ tmid: 't1' }); const onThreadPress = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress } }); + const { result } = renderMessageHook(useMessagePress, model, { config: { handlers: { onThreadPress } } }); act(() => result.current()); expect(onThreadPress).toHaveBeenCalledTimes(1); @@ -976,7 +1075,9 @@ describe('MessageStore', () => { const model = buildFakeModel({ tmid: 't1' }); const onThreadPress = jest.fn(); const closeEmojiAndAction = jest.fn(); - const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress, closeEmojiAndAction } }); + const { result } = renderMessageHook(useMessagePress, model, { + config: { closeEmojiAndAction, handlers: { onThreadPress } } + }); act(() => result.current()); @@ -989,7 +1090,9 @@ describe('MessageStore', () => { const model = buildFakeModel({ tmid: 't1' }); const onThreadPress = jest.fn(); const closeEmojiAndAction = jest.fn((action?: (params?: unknown) => void) => action?.()); - const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress, closeEmojiAndAction } }); + const { result } = renderMessageHook(useMessagePress, model, { + config: { closeEmojiAndAction, handlers: { onThreadPress } } + }); act(() => result.current()); @@ -999,7 +1102,7 @@ describe('MessageStore', () => { }); describe('translation boundary on item.autoTranslate', () => { - const translationConfig = { autoTranslateRoom: true, autoTranslateLanguage: 'pt-BR', user: { username: 'alice' } }; + const translationConfig = { autoTranslateRoom: true, autoTranslateLanguage: 'pt-BR' }; it('useTranslateLanguage returns undefined when autoTranslate is undefined', () => { const model = buildFakeModel({ autoTranslate: undefined, u: { _id: 'u2', username: 'bob' } }); diff --git a/app/containers/message/utils.ts b/app/containers/message/utils.ts index 060728c0c5b..49b17756fc5 100644 --- a/app/containers/message/utils.ts +++ b/app/containers/message/utils.ts @@ -2,8 +2,36 @@ import { type IAttachment, type IMessageTranslations } from '~/definitions'; import { type MessageTypesValues, type TAnyMessageModel, type TMessageModel } from '~/definitions/IMessage'; import I18n from '~/i18n'; +import dayjs from '~/lib/dayjs'; import { DISCUSSION } from './constants'; +export type TMessageSeparators = { dateSeparator: TAnyMessageModel['ts'] | null; showUnreadSeparator: boolean }; + +export const getMessageSeparators = ( + prev: TAnyMessageModel | undefined, + item: TAnyMessageModel, + lastSeen: Date | null +): TMessageSeparators => { + let dateSeparator: TAnyMessageModel['ts'] | null = null; + let showUnreadSeparator = false; + + const itemDate = dayjs(item.ts); + + if (!prev) { + dateSeparator = item.ts; + showUnreadSeparator = lastSeen ? itemDate.isAfter(lastSeen) : false; + } else { + const prevDate = dayjs(prev.ts); + showUnreadSeparator = + (lastSeen && (itemDate.isSame(lastSeen) || itemDate.isAfter(lastSeen)) && prevDate.isBefore(lastSeen)) ?? false; + if (!itemDate.isSame(prev.ts, 'day')) { + dateSeparator = item.ts; + } + } + + return { dateSeparator, showUnreadSeparator }; +}; + export const DEFAULT_MESSAGE_HEIGHT = 150; export const formatMessageCount = (count?: number, type?: string): string | null => { diff --git a/app/definitions/IRoomMessageHandlers.ts b/app/definitions/IRoomMessageHandlers.ts new file mode 100644 index 00000000000..7237594244c --- /dev/null +++ b/app/definitions/IRoomMessageHandlers.ts @@ -0,0 +1,27 @@ +import { type IAttachment } from './IAttachment'; +import { type IEmoji } from './IEmoji'; +import { type IMessage, type TAnyMessageModel } from './IMessage'; +import { type IRoomInfoParam } from './IRoom'; + +export interface IUseRoomMessageHandlersResult { + blockAction: (params: { + actionId: string; + appId: string; + value: any; + blockId: string; + rid: string; + mid: string; + }) => Promise; + navToRoomInfo: (navParam: IRoomInfoParam) => void; + handleEnterCall: () => void; + onDiscussionPress: (drid: TAnyMessageModel['drid']) => void; + onThreadPress: (item: TAnyMessageModel) => void; + onEncryptedPress: () => void; + showAttachment: (attachment: IAttachment) => void; + onReactionPress: (emoji: IEmoji, messageId: string) => Promise; + onReactionLongPress: (message: TAnyMessageModel) => void; + replyBroadcast: (message: IMessage) => void; + fetchThreadName: (threadId: string, messageId: string) => Promise; + toggleFollowThread: (isFollowingThread: boolean, threadId?: string) => Promise; + onAnswerButtonPress: (message?: string, tshow?: boolean) => void; +} diff --git a/app/definitions/TRoom.ts b/app/definitions/TRoom.ts new file mode 100644 index 00000000000..0dc2a5e6bb0 --- /dev/null +++ b/app/definitions/TRoom.ts @@ -0,0 +1,21 @@ +import { type ILastMessage } from './IMessage'; +import { type IVisitor, type TSubscriptionModel } from './ISubscription'; + +export type TPreviewRoom = { + rid: string; + t: string; + name?: string; + fname?: string; + prid?: string; + visitor?: IVisitor; + joinCodeRequired?: boolean; + lastMessage?: ILastMessage; + sysMes?: boolean; + onHold?: boolean; +}; + +export type TRoomOrPreview = TSubscriptionModel | TPreviewRoom; + +export const isSubscriptionModel = (room: TRoomOrPreview): room is TSubscriptionModel => 'id' in room; + +export const isPreviewRoom = (room: TRoomOrPreview): room is TPreviewRoom => !('id' in room); diff --git a/app/definitions/__tests__/TRoom.test.ts b/app/definitions/__tests__/TRoom.test.ts new file mode 100644 index 00000000000..608b78df623 --- /dev/null +++ b/app/definitions/__tests__/TRoom.test.ts @@ -0,0 +1,25 @@ +import { isPreviewRoom, isSubscriptionModel, type TPreviewRoom } from '../TRoom'; +import { type TSubscriptionModel } from '../ISubscription'; + +const subscription = { id: 'sub-1', rid: 'rid-1', t: 'c', name: 'general' } as TSubscriptionModel; +const preview: TPreviewRoom = { rid: 'rid-1', t: 'c', name: 'general' }; + +describe('isSubscriptionModel', () => { + it('returns true for a Subscription model', () => { + expect(isSubscriptionModel(subscription)).toBe(true); + }); + + it('returns false for a preview room', () => { + expect(isSubscriptionModel(preview)).toBe(false); + }); +}); + +describe('isPreviewRoom', () => { + it('returns true for a preview room', () => { + expect(isPreviewRoom(preview)).toBe(true); + }); + + it('returns false for a Subscription model', () => { + expect(isPreviewRoom(subscription)).toBe(false); + }); +}); diff --git a/app/definitions/index.ts b/app/definitions/index.ts index 45d4bdc2dd6..8e19366cca9 100644 --- a/app/definitions/index.ts +++ b/app/definitions/index.ts @@ -21,6 +21,7 @@ export * from './IPreferences'; export * from './IProfile'; export * from './IReaction'; export * from './IRole'; +export * from './IRoomMessageHandlers'; export * from './IRoom'; export * from './ISearch'; export * from './IServer'; diff --git a/app/definitions/redux/index.ts b/app/definitions/redux/index.ts index 2ce29806872..a7a6a9f4801 100644 --- a/app/definitions/redux/index.ts +++ b/app/definitions/redux/index.ts @@ -1,5 +1,6 @@ // ACTIONS import { type TActionInquiry } from '~/ee/omnichannel/actions/inquiry'; +import { type TActionRoutingConfig } from '~/ee/omnichannel/actions/routingConfig'; import { type TActionActiveUsers } from '~/actions/activeUsers'; import { type TActionApp } from '~/actions/app'; import { type TActionCreateChannel } from '~/actions/createChannel'; @@ -35,6 +36,7 @@ import { type IServer } from '~/reducers/server'; import { type TSettingsState } from '~/reducers/settings'; import { type IShare } from '~/reducers/share'; import { type IInquiry } from '~/ee/omnichannel/reducers/inquiry'; +import { type IRoutingConfig } from '~/ee/omnichannel/reducers/routingConfig'; import { type IPermissionsState } from '~/reducers/permissions'; import { type IEnterpriseModules } from '~/reducers/enterpriseModules'; import { type IVideoConf } from '~/reducers/videoConf'; @@ -67,6 +69,7 @@ export interface IApplicationState { inviteLinks: IInviteLinks; createDiscussion: ICreateDiscussion; inquiry: IInquiry; + routingConfig: IRoutingConfig; enterpriseModules: IEnterpriseModules; encryption: IEncryption; permissions: IPermissionsState; @@ -93,6 +96,7 @@ export type TApplicationActions = TActionActiveUsers & TActionServer & TActionApp & TActionInquiry & + TActionRoutingConfig & TActionPermissions & TActionEnterpriseModules & TActionVideoConf & diff --git a/app/ee/omnichannel/actions/routingConfig.ts b/app/ee/omnichannel/actions/routingConfig.ts new file mode 100644 index 00000000000..757722bfab4 --- /dev/null +++ b/app/ee/omnichannel/actions/routingConfig.ts @@ -0,0 +1,25 @@ +import { type Action } from 'redux'; + +import { ROUTING_CONFIG } from '~/actions/actionsTypes'; + +interface IRoutingConfigSuccess extends Action { + returnQueue: boolean; +} + +interface IRoutingConfigFailure extends Action { + error: unknown; +} + +export type TActionRoutingConfig = Action & Partial; + +export function routingConfigRequest(): Action { + return { type: ROUTING_CONFIG.REQUEST }; +} + +export function routingConfigSuccess(returnQueue: boolean): IRoutingConfigSuccess { + return { type: ROUTING_CONFIG.SUCCESS, returnQueue }; +} + +export function routingConfigFailure(error: unknown): IRoutingConfigFailure { + return { type: ROUTING_CONFIG.FAILURE, error }; +} diff --git a/app/ee/omnichannel/hooks/__tests__/useCanReturnQueue.test.tsx b/app/ee/omnichannel/hooks/__tests__/useCanReturnQueue.test.tsx new file mode 100644 index 00000000000..57d447d97d9 --- /dev/null +++ b/app/ee/omnichannel/hooks/__tests__/useCanReturnQueue.test.tsx @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; + +jest.mock('~/lib/services/restApi', () => ({ getRoutingConfig: jest.fn() })); + +import { getRoutingConfig } from '~/lib/services/restApi'; +import routingConfigSaga from '~/ee/omnichannel/sagas/routingConfig'; +import { createRecordingStore, cancelSagaTasks } from '~/lib/testUtils/sagaStore'; +import { useCanReturnQueue } from '../useCanReturnQueue'; +import type { ReactNode } from 'react'; + +const mockGetRoutingConfig = getRoutingConfig as jest.MockedFunction; + +afterEach(() => { + cancelSagaTasks(); + jest.clearAllMocks(); +}); + +const createWrapper = () => { + const recording = createRecordingStore(routingConfigSaga); + const Wrapper = ({ children }: { children: ReactNode }) => {children}; + return { ...recording, Wrapper }; +}; + +it('does not request when disabled and stays false while unloaded', () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useCanReturnQueue(false), { wrapper: Wrapper }); + + expect(result.current).toBe(false); + expect(mockGetRoutingConfig).not.toHaveBeenCalled(); +}); + +it('requests once when two consumers mount together', async () => { + mockGetRoutingConfig.mockResolvedValue({ returnQueue: true } as never); + const { Wrapper } = createWrapper(); + const first = renderHook(() => useCanReturnQueue(true), { wrapper: Wrapper }); + const second = renderHook(() => useCanReturnQueue(true), { wrapper: Wrapper }); + + await waitFor(() => expect(first.result.current).toBe(true)); + expect(second.result.current).toBe(true); + expect(mockGetRoutingConfig).toHaveBeenCalledTimes(1); +}); diff --git a/app/ee/omnichannel/hooks/useCanReturnQueue.ts b/app/ee/omnichannel/hooks/useCanReturnQueue.ts new file mode 100644 index 00000000000..7085d1c8b0f --- /dev/null +++ b/app/ee/omnichannel/hooks/useCanReturnQueue.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { routingConfigRequest } from '../actions/routingConfig'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; + +export function useCanReturnQueue(enabled: boolean): boolean { + const dispatch = useDispatch(); + const returnQueue = useAppSelector(state => state.routingConfig.returnQueue); + + useEffect(() => { + if (enabled && returnQueue === null) { + dispatch(routingConfigRequest()); + } + }, [dispatch, enabled, returnQueue]); + + return enabled && returnQueue === true; +} diff --git a/app/ee/omnichannel/lib/__tests__/takeInquiry.test.ts b/app/ee/omnichannel/lib/__tests__/takeInquiry.test.ts new file mode 100644 index 00000000000..3aff0e9d259 --- /dev/null +++ b/app/ee/omnichannel/lib/__tests__/takeInquiry.test.ts @@ -0,0 +1,38 @@ +import sdk from '~/lib/services/sdk'; +import { store as reduxStore } from '~/lib/store/auxStore'; +import { takeInquiry } from '..'; + +jest.mock('~/lib/services/sdk', () => ({ + __esModule: true, + default: { post: jest.fn(() => Promise.resolve()), get: jest.fn(), methodCallWrapper: jest.fn(() => Promise.resolve()) } +})); +jest.mock('~/lib/store/auxStore', () => ({ store: { getState: jest.fn() } })); +jest.mock('../subscriptions/inquiry', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('~/lib/methods/helpers/events', () => ({ + __esModule: true, + default: { addEventListener: jest.fn(), removeListener: jest.fn() } +})); + +const mockGetState = reduxStore.getState as jest.Mock; + +describe('takeInquiry', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses the REST endpoint on a modern Workspace', async () => { + mockGetState.mockReturnValue({ server: { version: '7.11.0' } }); + + await takeInquiry('inquiry-1'); + + expect(sdk.post).toHaveBeenCalledWith('livechat/inquiries.take', { inquiryId: 'inquiry-1' }); + expect(sdk.methodCallWrapper).not.toHaveBeenCalled(); + }); + + it('falls back to the DDP method on an older Workspace', async () => { + mockGetState.mockReturnValue({ server: { version: '7.10.0' } }); + + await takeInquiry('inquiry-1'); + + expect(sdk.methodCallWrapper).toHaveBeenCalledWith('livechat:takeInquiry', 'inquiry-1'); + expect(sdk.post).not.toHaveBeenCalled(); + }); +}); diff --git a/app/ee/omnichannel/lib/index.ts b/app/ee/omnichannel/lib/index.ts index 29ca1630f3e..2b18f7c81b8 100644 --- a/app/ee/omnichannel/lib/index.ts +++ b/app/ee/omnichannel/lib/index.ts @@ -28,7 +28,8 @@ export const getInquiriesQueued = (serverVersion: string) => { // this inquiry is added to the db by the subscriptions stream // and will be removed by the queue stream // RC 2.4.0 -export const takeInquiry = (inquiryId: string, serverVersion: string) => { +export const takeInquiry = (inquiryId: string) => { + const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.11.0')) { return sdk.post('livechat/inquiries.take', { inquiryId }); } diff --git a/app/ee/omnichannel/reducers/__tests__/routingConfig.test.ts b/app/ee/omnichannel/reducers/__tests__/routingConfig.test.ts new file mode 100644 index 00000000000..cd760c1408a --- /dev/null +++ b/app/ee/omnichannel/reducers/__tests__/routingConfig.test.ts @@ -0,0 +1,18 @@ +import { inquiryReset } from '~/ee/omnichannel/actions/inquiry'; +import { routingConfigSuccess } from '~/ee/omnichannel/actions/routingConfig'; +import { selectServerRequest } from '~/actions/server'; +import routingConfig, { initialState } from '../routingConfig'; + +describe('routingConfig reducer', () => { + it('stores the server value', () => { + expect(routingConfig(initialState, routingConfigSuccess(true))).toEqual({ returnQueue: true }); + }); + + it('clears the value when a server selection starts', () => { + expect(routingConfig({ returnQueue: true }, selectServerRequest('https://new.example', '7.0.0'))).toEqual(initialState); + }); + + it('does not change when the inquiry queue resets', () => { + expect(routingConfig({ returnQueue: true }, inquiryReset())).toEqual({ returnQueue: true }); + }); +}); diff --git a/app/ee/omnichannel/reducers/routingConfig.ts b/app/ee/omnichannel/reducers/routingConfig.ts new file mode 100644 index 00000000000..2898301abec --- /dev/null +++ b/app/ee/omnichannel/reducers/routingConfig.ts @@ -0,0 +1,22 @@ +import { type AnyAction } from 'redux'; + +import { ROUTING_CONFIG, SERVER } from '~/actions/actionsTypes'; + +export interface IRoutingConfig { + returnQueue: boolean | null; +} + +export const initialState: IRoutingConfig = { + returnQueue: null +}; + +export default function routingConfig(state = initialState, action: AnyAction): IRoutingConfig { + switch (action.type) { + case ROUTING_CONFIG.SUCCESS: + return { returnQueue: action.returnQueue }; + case SERVER.SELECT_REQUEST: + return initialState; + default: + return state; + } +} diff --git a/app/ee/omnichannel/sagas/__tests__/routingConfig.test.ts b/app/ee/omnichannel/sagas/__tests__/routingConfig.test.ts new file mode 100644 index 00000000000..fe66915cebe --- /dev/null +++ b/app/ee/omnichannel/sagas/__tests__/routingConfig.test.ts @@ -0,0 +1,39 @@ +jest.mock('~/lib/services/restApi', () => ({ getRoutingConfig: jest.fn() })); + +import { getRoutingConfig } from '~/lib/services/restApi'; +import { routingConfigRequest } from '~/ee/omnichannel/actions/routingConfig'; +import routingConfig from '../routingConfig'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '~/lib/testUtils/sagaStore'; + +const mockGetRoutingConfig = getRoutingConfig as jest.MockedFunction; + +afterEach(() => { + cancelSagaTasks(); + jest.clearAllMocks(); +}); + +it('loads once and retries after a failed request', async () => { + mockGetRoutingConfig.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ returnQueue: true } as never); + const { store } = createRecordingStore(routingConfig); + + store.dispatch(routingConfigRequest()); + await flushSagaMicrotasks(); + expect(store.getState().routingConfig.returnQueue).toBeNull(); + + store.dispatch(routingConfigRequest()); + await flushSagaMicrotasks(); + expect(store.getState().routingConfig.returnQueue).toBe(true); + expect(mockGetRoutingConfig).toHaveBeenCalledTimes(2); +}); + +it('ignores repeated requests after loading', async () => { + mockGetRoutingConfig.mockResolvedValue({ returnQueue: false } as never); + const { store } = createRecordingStore(routingConfig); + + store.dispatch(routingConfigRequest()); + store.dispatch(routingConfigRequest()); + await flushSagaMicrotasks(); + + expect(store.getState().routingConfig.returnQueue).toBe(false); + expect(mockGetRoutingConfig).toHaveBeenCalledTimes(1); +}); diff --git a/app/ee/omnichannel/sagas/routingConfig.ts b/app/ee/omnichannel/sagas/routingConfig.ts new file mode 100644 index 00000000000..42eb3358940 --- /dev/null +++ b/app/ee/omnichannel/sagas/routingConfig.ts @@ -0,0 +1,23 @@ +import { put, select, takeLeading } from 'redux-saga/effects'; + +import { ROUTING_CONFIG } from '~/actions/actionsTypes'; +import { getRoutingConfig } from '~/lib/services/restApi'; +import { routingConfigFailure, routingConfigSuccess } from '../actions/routingConfig'; + +const handleRequest = function* handleRequest(): Generator { + const returnQueue = yield select(state => state.routingConfig.returnQueue); + if (returnQueue !== null) { + return; + } + + try { + const routingConfig = yield getRoutingConfig(); + yield put(routingConfigSuccess(routingConfig.returnQueue)); + } catch (error) { + yield put(routingConfigFailure(error)); + } +}; + +export default function* routingConfig() { + yield takeLeading(ROUTING_CONFIG.REQUEST, handleRequest); +} diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 4d35dde54e3..874571d310e 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -522,6 +522,7 @@ "Review_this_app": "تقييم هذا التطبيق", "Rocket_Chat_Documentation": "توثيق Rocket.Chat", "Roles": "أدوار", + "Room_failed_to_load": "تعذر تحميل هذه الغرفة. تحقق من اتصالك وحاول مرة أخرى.", "Room_Info": "معلومات الغرفة", "Room_Info_Edit": "تعديل معلومات الغرفة", "Room_Password": "كلمة مرور الغرفة", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 8a69a51ecc8..17c242b47a3 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -714,6 +714,7 @@ "room_changed_topic_to": "রুম বিষয়বস্তু পরিবর্তন করেছে: {{topic}}", "room_changed_type": "রুম পরিবর্তন করেছে: {{type}}", "room_disallowed_reactions": "রিয়েকশন অনুমতি দেওয়া হয়নি", + "Room_failed_to_load": "এই রুমটি লোড করা যায়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন।", "Room_Info": "রুম তথ্য", "Room_Info_Edit": "রুম তথ্য সম্পাদনা", "Room_name_changed_to": "রুম নাম পরিবর্তন করেছে: {{name}}", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 6e4c8c6f34e..957e5cea9e4 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -762,6 +762,7 @@ "room_changed_topic_to": "změnil téma místnosti na: {{topic}}", "room_changed_type": "změnil pokoj na {{type}}", "room_disallowed_reactions": "nepovolené reakce", + "Room_failed_to_load": "Tuto místnost se nepodařilo načíst. Zkontrolujte připojení a zkuste to znovu.", "Room_Info": "Informace o pokoji", "Room_Info_Edit": "Úprava informací o místnosti", "Room_name_changed_to": "změnil název místnosti na: {{name}}", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index f1c8a29e759..4af39fdeab6 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -702,6 +702,7 @@ "room_changed_topic_to": "hat das Thema des Raumes geändert zu: {{topic}}", "room_changed_type": "hat den Raum geändert zu {{type}}", "room_disallowed_reactions": "hat Reaktionen untersagt", + "Room_failed_to_load": "Dieser Raum konnte nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.", "Room_Info": "Room-Info", "Room_Info_Edit": "Room-Info bearbeiten", "Room_name_changed_to": "hat den Namen des Raumes geändert zu: {{name}}", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 1bba0c0b456..4a0809c15a1 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -787,6 +787,7 @@ "room_changed_topic_to": "changed room topic to: {{topic}}", "room_changed_type": "changed room to {{type}}", "room_disallowed_reactions": "disallowed reactions", + "Room_failed_to_load": "This room could not be loaded. Check your connection and try again.", "Room_Info": "Room info", "Room_Info_Edit": "Room info edit", "Room_name_changed_to": "changed room name to: {{name}}", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index 7764864bce3..f590403f9e8 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -388,6 +388,7 @@ "Review_message": "Revisar mensaje", "Rocket_Chat_Documentation": "Documentación de Rocket.Chat", "Roles": "Roles", + "Room_failed_to_load": "No se pudo cargar esta sala. Comprueba tu conexión e inténtalo de nuevo.", "Room_Info": "Información de la sala", "Room_Info_Edit": "Editar información de la sala", "Room_Password": "Contraseña de habitación", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 9886d2e949e..19a11f6f096 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -677,6 +677,7 @@ "room_changed_topic_to": "vaihtoi huoneen aiheeksi: {{topic}}", "room_changed_type": "vaihtoi huoneen tyypiksi {{type}}", "room_disallowed_reactions": "kielsi reaktiot", + "Room_failed_to_load": "Tätä huonetta ei voitu ladata. Tarkista yhteytesi ja yritä uudelleen.", "Room_Info": "Huoneen tiedot", "Room_Info_Edit": "Huoneen tietojen muokkaus", "Room_name_changed_to": "vaihtoi huoneen nimeksi: {{name}}", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index f6614226b73..ae37eb4740f 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -627,6 +627,7 @@ "Review_this_app": "Donnez votre avis sur cette application", "Rocket_Chat_Documentation": "Documentation Rocket.Chat", "Roles": "Rôles", + "Room_failed_to_load": "Ce salon n'a pas pu être chargé. Vérifiez votre connexion et réessayez.", "Room_Info": "Info sur le salon", "Room_Info_Edit": "Modifier les informations du salon", "Room_Password": "Mot de passe de la salle", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 5452d373c3c..75c383b403f 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -714,6 +714,7 @@ "room_changed_topic_to": "कमरे का विषय बदल गया है: {{topic}}", "room_changed_type": "कमरे को बदल दिया गया है {{type}} में", "room_disallowed_reactions": "प्रतिक्रियाएँ निषिद्ध हैं", + "Room_failed_to_load": "यह रूम लोड नहीं हो सका। अपना कनेक्शन जाँचें और पुनः प्रयास करें।", "Room_Info": "कमरे की जानकारी", "Room_Info_Edit": "कमरे की जानकारी संपादित करें", "Room_name_changed_to": "कमरे का नाम बदल गया है: {{name}}", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 117ff7ef47d..1c274001778 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -715,6 +715,7 @@ "room_changed_topic_to": "szoba témája megváltoztatva erre: {{topic}}", "room_changed_type": "szoba megváltoztatva erre: {{type}}", "room_disallowed_reactions": "reagálások letiltva", + "Room_failed_to_load": "Ezt a szobát nem sikerült betölteni. Ellenőrizd a kapcsolatot, és próbáld újra.", "Room_Info": "Szoba info", "Room_Info_Edit": "Szoba információ szerkesztés", "Room_name_changed_to": "szoba neve megváltoztatva erre: {{name}}", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 69802190629..20f08388d0f 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -557,6 +557,7 @@ "Review_this_app": "Recensisci questa app", "Rocket_Chat_Documentation": "Documentazione Rocket.Chat", "Roles": "Ruoli", + "Room_failed_to_load": "Impossibile caricare questa stanza. Controlla la connessione e riprova.", "Room_Info": "Informazioni stanza", "Room_Info_Edit": "Modifica informazioni stanza", "Room_Password": "Password della stanza", diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 8fe1503b634..41cd0fd4c3a 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -473,6 +473,7 @@ "Review_this_app": "アプリをレビューする", "Rocket_Chat_Documentation": "Rocket.Chat ドキュメント", "Roles": "ロール", + "Room_failed_to_load": "このルームを読み込めませんでした。接続を確認して、もう一度お試しください。", "Room_Info": "ルーム情報", "Room_Info_Edit": "ルーム情報を編集", "Room_Password": "部屋のパスワード", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index c66230e9585..d1801286da3 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -627,6 +627,7 @@ "Review_this_app": "Beoordeel deze app", "Rocket_Chat_Documentation": "Rocket.Chat documentatie", "Roles": "Rollen", + "Room_failed_to_load": "Deze ruimte kon niet worden geladen. Controleer je verbinding en probeer het opnieuw.", "Room_Info": "Kamer info", "Room_Info_Edit": "Kamer info bewerken", "Room_Password": "Kamerwachtwoord", diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index cd8176e6683..88bcc574c4f 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -361,6 +361,7 @@ "Roles": "Roller", "room_changed_type": "endret rommet til {{room_type}}", "room_disallowed_reactions": "ikke tillatte reaksjoner", + "Room_failed_to_load": "Dette rommet kunne ikkje lastast. Sjekk tilkoplinga di og prøv igjen.", "Room_not_found": "Rom ikke funnet", "Save": "Lagre", "Save_Changes": "Lagre endringer", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 79c8ce16416..b9a643c907c 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -750,6 +750,7 @@ "room_changed_topic_to": "endret romemne til: {{topic}}", "room_changed_type": "endret rom til {{type}}", "room_disallowed_reactions": "ikke tillatte reaksjoner", + "Room_failed_to_load": "Dette rommet kunne ikke lastes. Sjekk tilkoblingen din og prøv igjen.", "Room_Info": "Rominformasjon", "Room_Info_Edit": "Rediger rominformasjon", "Room_name_changed_to": "endret romnavn til: {{name}}", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 444f5d02101..6e6322cde1f 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -771,6 +771,7 @@ "room_changed_topic_to": "mudou tópico da sala para: {{topic}}", "room_changed_type": "mudou sala para {{type}}", "room_disallowed_reactions": "removeu a permissão de reagir", + "Room_failed_to_load": "Não foi possível carregar esta sala. Verifique sua conexão e tente novamente.", "Room_Info": "Informações da sala", "Room_Info_Edit": "Editar", "Room_name_changed_to": "alterou o nome da sala para: {{name}}", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 2bd4c3bbd2d..d7d39e650a8 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -476,6 +476,7 @@ "Review_message": "Rever mensagem", "Rocket_Chat_Documentation": "Documentação do Rocket.Chat", "Roles": "Funções", + "Room_failed_to_load": "Não foi possível carregar esta sala. Verifique a sua ligação e tente novamente.", "Room_Info": "Informação da Sala", "Room_Info_Edit": "Editar Informação da Sala", "Room_Password": "Senha da sala", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index ab7a020d886..ad350745b44 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -658,6 +658,7 @@ "Review_this_app": "Оценить это приложение", "Rocket_Chat_Documentation": "Документация Rocket.Chat", "Roles": "Роли", + "Room_failed_to_load": "Не удалось загрузить эту комнату. Проверьте подключение и попробуйте снова.", "Room_Info": "Информация о канале", "Room_Info_Edit": "Изменить информацию о чате", "Room_Password": "Пароль комнаты", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 337ccdac78e..a7817e3d830 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -640,6 +640,7 @@ "Review_this_app": "Preglejte to aplikacijo", "Rocket_Chat_Documentation": "Dokumentacija Rocket.Chat", "Roles": "Vloge", + "Room_failed_to_load": "Te sobe ni bilo mogoče naložiti. Preverite povezavo in poskusite znova.", "Room_Info": "Informacije o sobi", "Room_Info_Edit": "Uredi informacije o sobi", "Room_Password": "Sobno geslo", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index f5c51a4e6a3..ab2f2b6c0b3 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -676,6 +676,7 @@ "room_changed_topic_to": "ändrade rummets ämne till: {{topic}}", "room_changed_type": "ändrade rummet till {{type}}", "room_disallowed_reactions": "otillåtna reaktioner", + "Room_failed_to_load": "Det gick inte att ladda det här rummet. Kontrollera din anslutning och försök igen.", "Room_Info": "Information om rum", "Room_Info_Edit": "Redigera information om rum", "Room_name_changed_to": "ändrade rummets namn till: {{name}}", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index f2f84105f36..14a0b567050 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -714,6 +714,7 @@ "room_changed_topic_to": "அறை தலைப்பு மாற்றினது: {{topic}}", "room_changed_type": "அறை வகை மாற்றினது: {{type}}", "room_disallowed_reactions": "பிரதிகிதங்களை அனுமதிக்கப்படவில்லை", + "Room_failed_to_load": "இந்த அறையை ஏற்ற முடியவில்லை. உங்கள் இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும்.", "Room_Info": "அறை தகவல்", "Room_Info_Edit": "அறை தகவல் திருத்து", "Room_name_changed_to": "அறை பெயர் மாற்றினது: {{name}}", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index 0b8c22c9e9e..661e4f51b98 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -713,6 +713,7 @@ "room_changed_topic_to": "గది టాపిక్‌ను మార్చారు: {{topic}}", "room_changed_type": "గది రకాన్ని మార్చారు {{type}}", "room_disallowed_reactions": "గదిలో ప్రతిక్రియలను అనుమతించలేదు", + "Room_failed_to_load": "ఈ గదిని లోడ్ చేయలేకపోయాము. మీ కనెక్షన్‌ను తనిఖీ చేసి మళ్లీ ప్రయత్నించండి.", "Room_Info": "గది సమాచారం", "Room_Info_Edit": "గది సమాచారం సవరించు", "Room_name_changed_to": "గది పేరును మార్చారు: {{name}}", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 4878139e93d..dd2dc95dbce 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -540,6 +540,7 @@ "Review_this_app": "Bu uygulamayı değerlendirin", "Rocket_Chat_Documentation": "Rocket.Chat dokümantasyonu", "Roles": "Roller", + "Room_failed_to_load": "Bu oda yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin.", "Room_Info": "Oda Bilgisi", "Room_Info_Edit": "Oda Bilgilerini Düzenle", "Room_Password": "Oda şifresi", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 64f5555cd9b..63d65844017 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -521,6 +521,7 @@ "Review_this_app": "评分此 App", "Rocket_Chat_Documentation": "Rocket.Chat 文档", "Roles": "角色", + "Room_failed_to_load": "无法加载此房间。请检查网络连接后重试。", "Room_Info": "聊天室信息", "Room_Info_Edit": "聊天室信息编辑", "Room_Password": "房间密码", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 72f08f4b61b..4c5ae9b4029 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -539,6 +539,7 @@ "Review_this_app": "評分此 App", "Rocket_Chat_Documentation": "Rocket.Chat 文件", "Roles": "角色", + "Room_failed_to_load": "無法載入此聊天室。請檢查你的連線後再試一次。", "Room_Info": "聊天室資訊", "Room_Info_Edit": "修改聊天室資訊", "Room_Password": "房間密碼", diff --git a/app/lib/database/model/__tests__/Subscription.memo.test.ts b/app/lib/database/model/__tests__/Subscription.memo.test.ts new file mode 100644 index 00000000000..b33a1f0f8ef --- /dev/null +++ b/app/lib/database/model/__tests__/Subscription.memo.test.ts @@ -0,0 +1,51 @@ +import Subscription from '../Subscription'; + +// RoomStore's field selectors depend on an unchanged @json field returning the same reference +// across observe() emits; that stability comes from WatermelonDB's `{ memo: true }` option. +const makeRecord = (raw: Record) => { + const record = Object.create(Subscription.prototype); + record._raw = raw; + return record; +}; + +describe.each([ + ['tunread', 'tunread', JSON.stringify(['1', '2']), JSON.stringify(['1', '2', '3'])], + ['tunreadUser', 'tunread_user', JSON.stringify(['1']), JSON.stringify(['1', '2'])], + ['tunreadGroup', 'tunread_group', JSON.stringify(['1']), JSON.stringify(['1', '2'])], + ['visitor', 'visitor', JSON.stringify({ status: 'online' }), JSON.stringify({ status: 'away' })], + ['source', 'source', JSON.stringify({ type: 'app' }), JSON.stringify({ type: 'widget' })], + [ + 'abacAttributes', + 'abac_attributes', + JSON.stringify([{ key: 'k', values: ['v'] }]), + JSON.stringify([{ key: 'k', values: ['v', 'w'] }]) + ], + ['ignored', 'ignored', JSON.stringify(['uid-1']), JSON.stringify(['uid-1', 'uid-2'])], + ['lastMessage', 'last_message', JSON.stringify({ msg: 'hi' }), JSON.stringify({ msg: 'bye' })] +])('Subscription %s @json memo', (field, column, rawValue, changedRawValue) => { + it('returns the identical reference on repeated reads', () => { + const record = makeRecord({ [column]: rawValue }); + + expect(record[field]).toBe(record[field]); + }); + + it('returns a new reference after the raw column value changes', () => { + const record = makeRecord({ [column]: rawValue }); + + const before = record[field]; + record._raw[column] = changedRawValue; + const after = record[field]; + + expect(after).not.toBe(before); + }); + + it('keeps the same reference when an unrelated column changes', () => { + const record = makeRecord({ [column]: rawValue, unread: 0 }); + + const before = record[field]; + record._raw.unread = 1; + const after = record[field]; + + expect(after).toBe(before); + }); +}); diff --git a/app/lib/hooks/__tests__/useObservable.test.ts b/app/lib/hooks/__tests__/useObservable.test.ts new file mode 100644 index 00000000000..a4e5b4a8b66 --- /dev/null +++ b/app/lib/hooks/__tests__/useObservable.test.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Observable, Subject } from 'rxjs'; + +import { useObservable } from '../useObservable'; + +describe('useObservable', () => { + it('returns undefined until the observable emits, then the latest value', () => { + const subject = new Subject(); + const { result } = renderHook(() => useObservable(subject)); + + expect(result.current).toBeUndefined(); + act(() => subject.next(1)); + expect(result.current).toBe(1); + act(() => subject.next(2)); + expect(result.current).toBe(2); + }); + + it('drops the previous value when the observable changes', () => { + const first = new Subject(); + const second = new Subject(); + const { result, rerender } = renderHook(({ source }: { source: Subject }) => useObservable(source), { + initialProps: { source: first } + }); + + act(() => first.next('first')); + expect(result.current).toBe('first'); + + rerender({ source: second }); + expect(result.current).toBeUndefined(); + act(() => second.next('second')); + expect(result.current).toBe('second'); + }); + + it('unsubscribes on unmount', () => { + const teardown = jest.fn(); + const observable = new Observable(() => teardown); + const { unmount } = renderHook(() => useObservable(observable)); + expect(teardown).not.toHaveBeenCalled(); + unmount(); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('returns undefined without an observable', () => { + const { result } = renderHook(() => useObservable(undefined)); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/app/lib/hooks/__tests__/usePermissions.test.ts b/app/lib/hooks/__tests__/usePermissions.test.ts new file mode 100644 index 00000000000..38967d96f54 --- /dev/null +++ b/app/lib/hooks/__tests__/usePermissions.test.ts @@ -0,0 +1,84 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import database from '~/lib/database'; +import { store as reduxStore } from '~/lib/store/auxStore'; +import { getSubscriptionByRoomId } from '~/lib/database/services/Subscription'; +import { usePermissions } from '../usePermissions'; + +let mockState: any; +jest.mock('../useAppSelector', () => ({ + useAppSelector: (selector: (state: any) => unknown) => selector(mockState) +})); + +jest.mock('~/lib/database', () => ({ __esModule: true, default: { active: { get: jest.fn() } } })); +jest.mock('~/lib/store/auxStore', () => ({ store: { getState: jest.fn() } })); +jest.mock('~/lib/database/services/Subscription', () => ({ getSubscriptionByRoomId: jest.fn() })); + +const mockGet = database.active.get as jest.Mock; +const mockGetState = reduxStore.getState as jest.Mock; +const mockGetSubscriptionByRoomId = getSubscriptionByRoomId as jest.Mock; + +const PERMISSION = 'toggle-room-e2e-encryption'; + +const configureRoles = ({ + userRoles, + subRoles, + permissionRoles +}: { + userRoles: string[]; + subRoles: string[]; + permissionRoles: string[]; +}) => { + mockState = { + login: { user: { roles: userRoles } }, + permissions: { [PERMISSION]: permissionRoles } + }; + mockGetState.mockReturnValue({ login: { user: { roles: userRoles } } }); + + const subRecord = { + roles: subRoles, + observe: () => ({ + subscribe: (cb: (sub: any) => void) => { + cb({ roles: subRoles }); + return { unsubscribe: jest.fn() }; + } + }) + }; + mockGetSubscriptionByRoomId.mockResolvedValue(subRecord); + mockGet.mockImplementation(() => ({ find: jest.fn(() => Promise.resolve(subRecord)) })); +}; + +const flush = () => act(() => Promise.resolve()); + +describe('usePermissions', () => { + beforeEach(() => jest.clearAllMocks()); + + const scenarios = [ + { name: 'user role grants the permission', userRoles: ['admin'], subRoles: [], permissionRoles: ['admin'], expected: true }, + { + name: 'subscription role grants the permission', + userRoles: [], + subRoles: ['owner'], + permissionRoles: ['owner'], + expected: true + }, + { + name: "the user's roles lack the permission", + userRoles: ['user'], + subRoles: ['moderator'], + permissionRoles: ['owner'], + expected: false + } + ]; + + scenarios.forEach(scenario => { + it(`grants ${scenario.expected} when ${scenario.name}`, async () => { + configureRoles(scenario); + + const { result } = renderHook(() => usePermissions([PERMISSION], 'rid-1')); + await flush(); + + expect(result.current[0]).toBe(scenario.expected); + }); + }); +}); diff --git a/app/lib/hooks/useLiveRef.test.ts b/app/lib/hooks/useLiveRef.test.ts new file mode 100644 index 00000000000..23ba4d57524 --- /dev/null +++ b/app/lib/hooks/useLiveRef.test.ts @@ -0,0 +1,30 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useLiveRef } from './useLiveRef'; + +describe('useLiveRef', () => { + it('exposes the initial value on first render', () => { + const { result } = renderHook(() => useLiveRef('first')); + + expect(result.current.current).toBe('first'); + }); + + it('refreshes ref.current to the latest value on rerender', () => { + const { result, rerender } = renderHook(({ value }: { value: number }) => useLiveRef(value), { initialProps: { value: 1 } }); + + rerender({ value: 2 }); + + expect(result.current.current).toBe(2); + }); + + it('keeps a stable ref object identity across rerenders', () => { + const { result, rerender } = renderHook(({ value }: { value: string }) => useLiveRef(value), { + initialProps: { value: 'a' } + }); + const firstRef = result.current; + + rerender({ value: 'b' }); + + expect(result.current).toBe(firstRef); + }); +}); diff --git a/app/lib/hooks/useLiveRef.ts b/app/lib/hooks/useLiveRef.ts new file mode 100644 index 00000000000..e99597db6cc --- /dev/null +++ b/app/lib/hooks/useLiveRef.ts @@ -0,0 +1,12 @@ +import { type RefObject, useEffect, useRef } from 'react'; + +// Ref refreshed every render (no deps array), so a narrowly-keyed effect can read the latest closure +// without listing it as a dependency. Blessed escape hatch for the intentional deps-less mirror. +// Stand-in for useEffectEvent (stable in React 19.2); migrate 1:1 once RN ships React >= 19.2. +export function useLiveRef(value: T): RefObject { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }); + return ref; +} diff --git a/app/lib/hooks/useObservable.ts b/app/lib/hooks/useObservable.ts new file mode 100644 index 00000000000..8f587c446a0 --- /dev/null +++ b/app/lib/hooks/useObservable.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { type Observable } from 'rxjs'; + +type Emission = { source: Observable; value: T }; + +export function useObservable(observable: Observable | undefined): T | undefined { + const latestEmission = useRef | undefined>(undefined); + + const subscribe = useCallback( + (onChange: () => void) => { + if (!observable) { + return () => {}; + } + const subscription = observable.subscribe(value => { + latestEmission.current = { source: observable, value }; + onChange(); + }); + return () => subscription.unsubscribe(); + }, + [observable] + ); + + return useSyncExternalStore(subscribe, () => { + const emission = latestEmission.current; + return emission && emission.source === observable ? emission.value : undefined; + }); +} diff --git a/app/lib/hooks/useShortnameToUnicode/index.tsx b/app/lib/hooks/useShortnameToUnicode/index.tsx index 4805c1246e0..a3ddac113ff 100644 --- a/app/lib/hooks/useShortnameToUnicode/index.tsx +++ b/app/lib/hooks/useShortnameToUnicode/index.tsx @@ -36,7 +36,6 @@ const useShortnameToUnicode = (isEmojiPicker?: boolean) => { const replaceShortnameWithUnicode = (shortname: string) => { const name = shortname.replace(/:/g, ''); - // a custom emoji sharing a built-in shortcode/alias must win if (customEmojis(name)) { return shortname; diff --git a/app/lib/methods/emojis.ts b/app/lib/methods/emojis.ts index 490ac8e7d01..b0227700d5e 100644 --- a/app/lib/methods/emojis.ts +++ b/app/lib/methods/emojis.ts @@ -38,7 +38,7 @@ export const searchEmojiNames = (keyword: string): string[] => { // Looked up by content, never used as the record id: emoji content / custom names can be // non-ASCII and corrupt across the native SQLite bridge when used as WatermelonDB ids. -const getEmojiContent = (emoji: IEmoji) => (typeof emoji === 'string' ? emoji : emoji.name); +export const getEmojiContent = (emoji: IEmoji) => (typeof emoji === 'string' ? emoji : emoji.name); export const addFrequentlyUsed = async (emoji: IEmoji) => { const db = database.active; diff --git a/app/lib/methods/getInvitationData.ts b/app/lib/methods/getInvitationData.ts index 01351da3402..a11cfc35fa4 100644 --- a/app/lib/methods/getInvitationData.ts +++ b/app/lib/methods/getInvitationData.ts @@ -3,7 +3,7 @@ import I18n from '~/i18n'; import { getRoomTitle } from './helpers'; import { replyRoomInvite } from './replyRoomInvite'; -export const getInvitationData = (room: IInviteSubscription) => { +export const getInvitationText = (room: Pick) => { const title = room.t === 'd' ? I18n.t('invited_room_title_dm') @@ -14,8 +14,11 @@ export const getInvitationData = (room: IInviteSubscription) => { return { title, description, - inviter: room.inviter, - accept: () => replyRoomInvite(room.id, 'accept'), - reject: () => replyRoomInvite(room.id, 'reject') + inviter: room.inviter }; }; + +export const getInvitationActions = (room: Pick) => ({ + accept: () => replyRoomInvite(room.id, 'accept'), + reject: () => replyRoomInvite(room.id, 'reject') +}); diff --git a/app/lib/methods/helpers/debounce.ts b/app/lib/methods/helpers/debounce.ts index f792d965d34..1acf491e8e7 100644 --- a/app/lib/methods/helpers/debounce.ts +++ b/app/lib/methods/helpers/debounce.ts @@ -1,4 +1,4 @@ -import { useDebouncedCallback, type Options } from 'use-debounce'; +import { useDebouncedCallback, type DebouncedState, type Options } from 'use-debounce'; export function debounce(func: Function, wait?: number, immediate?: boolean) { let timeout: ReturnType | null; @@ -24,6 +24,6 @@ export function debounce(func: Function, wait?: number, immediate?: boolean) { return _debounce; } -export function useDebounce(func: (...args: any) => any, wait?: number, options?: Options): (...args: any[]) => void { +export function useDebounce any>(func: T, wait?: number, options?: Options): DebouncedState { return useDebouncedCallback(func, wait || 1000, options); } diff --git a/app/lib/methods/helpers/goRoom.test.ts b/app/lib/methods/helpers/goRoom.test.ts new file mode 100644 index 00000000000..77783ff3563 --- /dev/null +++ b/app/lib/methods/helpers/goRoom.test.ts @@ -0,0 +1,20 @@ +import { goRoom } from './goRoom'; + +jest.mock('~/lib/navigation/appNavigation', () => ({ + __esModule: true, + default: { + getCurrentRoute: jest.fn(() => ({ name: 'RoomsListView' })), + setParams: jest.fn(), + popTo: jest.fn(), + dispatch: jest.fn() + } +})); +jest.mock('~/lib/database/services/Subscription', () => ({ getSubscriptionByRoomId: jest.fn(() => Promise.resolve(null)) })); +jest.mock('./helpers', () => ({ getRoomTitle: jest.fn(() => 'Room'), getUidDirectMessage: jest.fn() })); + +describe('goRoom navigation', () => { + it('navigates without creating a RoomStore during the transition', async () => { + await goRoom({ item: { rid: 'r1', t: 'c' as any }, isMasterDetail: false } as any); + expect(true).toBe(true); + }); +}); diff --git a/app/lib/methods/helpers/goRoom.ts b/app/lib/methods/helpers/goRoom.ts index 8ba353fa434..c0b340125d1 100644 --- a/app/lib/methods/helpers/goRoom.ts +++ b/app/lib/methods/helpers/goRoom.ts @@ -21,18 +21,29 @@ interface IGoRoomItem { name?: string; prid?: string; visitor?: IVisitor; + joinCodeRequired?: boolean; } export type TGoRoomItem = IGoRoomItem | TSubscriptionModel | ISubscription | IOmnichannelRoomVisitor; +interface RoomRouteParams { + rid?: string; + name: string; + t?: SubscriptionType; + prid?: string; + visitor?: IVisitor; + joinCodeRequired?: boolean; + roomUserId?: string; +} + const navigate = ({ item, isMasterDetail, ...props }: { item: TGoRoomItem; isMasterDetail: boolean }) => { - const routeParams = { + const routeParams: RoomRouteParams = { rid: item.rid, name: getRoomTitle(item), t: item.t, prid: item.prid, - room: item, visitor: item.visitor, + joinCodeRequired: item.joinCodeRequired, roomUserId: getUidDirectMessage(item), ...props }; @@ -113,11 +124,7 @@ export const goRoom = async ({ } } - /** - * Fetches subscription from database to use goRoom with a more complete room object. - * We might want to review this logic in the future, since react-navigation complains about non-serializable data - * https://reactnavigation.org/docs/troubleshooting/#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state - */ + // Re-fetch the subscription so scalar route params (name, prid, visitor, roomUserId) are as complete as possible. let _item = item; if (item.rid) { const sub = await getSubscriptionByRoomId(item.rid); diff --git a/app/lib/methods/helpers/isReadOnly.test.ts b/app/lib/methods/helpers/isReadOnly.test.ts new file mode 100644 index 00000000000..81eb35a586d --- /dev/null +++ b/app/lib/methods/helpers/isReadOnly.test.ts @@ -0,0 +1,97 @@ +import { isReadOnly, isReadOnlySync } from './isReadOnly'; +import { hasPermission } from './helpers'; +import { store as reduxStore } from '~/lib/store/auxStore'; + +jest.mock('./helpers', () => ({ + hasPermission: jest.fn() +})); + +jest.mock('~/lib/store/auxStore', () => ({ + store: { getState: jest.fn() } +})); + +const mockedHasPermission = hasPermission as jest.Mock; +const mockedGetState = (reduxStore as unknown as { getState: jest.Mock }).getState; + +const username = 'user1'; +const postReadOnlyPermission = ['admin']; + +beforeEach(() => { + jest.clearAllMocks(); + mockedGetState.mockReturnValue({ permissions: { 'post-readonly': postReadOnlyPermission } }); + mockedHasPermission.mockResolvedValue([false]); +}); + +describe('isReadOnly (async)', () => { + test('archived room is read only', async () => { + expect(await isReadOnly({ archived: true }, username)).toBe(true); + expect(mockedHasPermission).not.toHaveBeenCalled(); + }); + + test('muted user is read only', async () => { + expect(await isReadOnly({ muted: [username] }, username)).toBe(true); + expect(mockedHasPermission).not.toHaveBeenCalled(); + }); + + test('non-ro room is not read only', async () => { + expect(await isReadOnly({}, username)).toBe(false); + expect(mockedHasPermission).not.toHaveBeenCalled(); + }); + + test('ro + archived returns true without fetching permission', async () => { + expect(await isReadOnly({ ro: true, rid: 'rid1', archived: true }, username)).toBe(true); + expect(mockedHasPermission).not.toHaveBeenCalled(); + }); + + test('ro + muted returns true without fetching permission', async () => { + expect(await isReadOnly({ ro: true, rid: 'rid1', muted: [username] }, username)).toBe(true); + expect(mockedHasPermission).not.toHaveBeenCalled(); + }); + + test('ro room without post permission is read only', async () => { + mockedHasPermission.mockResolvedValue([false]); + expect(await isReadOnly({ ro: true, rid: 'rid1' }, username)).toBe(true); + }); + + test('ro room with post permission is not read only', async () => { + mockedHasPermission.mockResolvedValue([true]); + expect(await isReadOnly({ ro: true, rid: 'rid1' }, username)).toBe(false); + }); + + test('ro room with unmuted user is not read only', async () => { + mockedHasPermission.mockResolvedValue([false]); + expect(await isReadOnly({ ro: true, rid: 'rid1', unmuted: [username] }, username)).toBe(false); + }); +}); + +describe('isReadOnlySync', () => { + test('archived room is read only', () => { + expect(isReadOnlySync({ archived: true }, username, postReadOnlyPermission, [])).toBe(true); + }); + + test('muted user is read only', () => { + expect(isReadOnlySync({ muted: [username] }, username, postReadOnlyPermission, [])).toBe(true); + }); + + test('non-ro room is not read only', () => { + expect(isReadOnlySync({}, username, postReadOnlyPermission, [])).toBe(false); + }); + + test('ro room without matching role is read only', () => { + expect(isReadOnlySync({ ro: true, roles: ['user'] }, username, postReadOnlyPermission, ['user'])).toBe(true); + }); + + test('ro room with matching room role is not read only', () => { + expect(isReadOnlySync({ ro: true, roles: ['admin'] }, username, postReadOnlyPermission, [])).toBe(false); + }); + + test('ro room with matching user role is not read only', () => { + expect(isReadOnlySync({ ro: true, roles: [] }, username, postReadOnlyPermission, ['admin'])).toBe(false); + }); + + test('ro room with unmuted user is not read only', () => { + expect(isReadOnlySync({ ro: true, roles: ['user'], unmuted: [username] }, username, postReadOnlyPermission, ['user'])).toBe( + false + ); + }); +}); diff --git a/app/lib/methods/helpers/isReadOnly.ts b/app/lib/methods/helpers/isReadOnly.ts index d186894f229..b0fd102c4dc 100644 --- a/app/lib/methods/helpers/isReadOnly.ts +++ b/app/lib/methods/helpers/isReadOnly.ts @@ -2,6 +2,8 @@ import { store as reduxStore } from '~/lib/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); @@ -14,7 +16,7 @@ const canPostReadOnly = async (room: Partial, username?: string) const isMuted = (room: Partial, username?: string) => room && room.muted && room.muted.find && !!room.muted.find(m => m === username); -export const isReadOnly = async (room: Partial, username?: string): Promise => { +const evaluateReadOnly = (room: Partial, username: string | undefined, allowPost: boolean): boolean => { if (room.archived) { return true; } @@ -22,11 +24,30 @@ export const isReadOnly = async (room: Partial, username?: string return true; } if (room?.ro) { - const allowPost = await canPostReadOnly(room, username); - if (allowPost) { - return false; - } - return true; + return !allowPost; } return false; }; + +export const isReadOnly = async (room: ReadOnlyRoom, username?: string): Promise => { + if (room.archived || isMuted(room, username)) { + return true; + } + const allowPost = room?.ro ? await canPostReadOnly(room, username) : false; + return evaluateReadOnly(room, username, allowPost); +}; + +export const isReadOnlySync = ( + room: Partial, + username: string, + postReadOnlyPermission: string[] | undefined, + userRoles: string[] +): boolean => { + let allowPost = false; + if (room?.ro) { + const isUnmuted = !!room?.unmuted?.find(m => m === username); + const mergedRoles = [...new Set([...(room.roles || []), ...userRoles])]; + allowPost = !!postReadOnlyPermission?.some(r => mergedRoles.includes(r)) || isUnmuted; + } + return evaluateReadOnly(room, username, allowPost); +}; diff --git a/app/lib/methods/helpers/log/events.ts b/app/lib/methods/helpers/log/events.ts index 7817983f2db..8952e03379c 100644 --- a/app/lib/methods/helpers/log/events.ts +++ b/app/lib/methods/helpers/log/events.ts @@ -229,6 +229,7 @@ export default { ROOM_TOGGLE_FOLLOW_THREADS: 'room_toggle_follow_threads', ROOM_GO_TEAM_CHANNELS: 'room_go_team_channels', ROOM_GO_SEARCH: 'room_go_search', + ROOM_GO_E2EE: 'room_go_e2ee', ROOM_GO_THREADS: 'room_go_threads', ROOM_GO_ROOM_INFO: 'room_go_room_info', ROOM_GO_USER_INFO: 'room_go_user_info', diff --git a/app/lib/methods/helpers/room.ts b/app/lib/methods/helpers/room.ts index a07852dbd26..e1fe799dc80 100644 --- a/app/lib/methods/helpers/room.ts +++ b/app/lib/methods/helpers/room.ts @@ -1,10 +1,10 @@ import dayjs from '~/lib/dayjs'; import { themes } from '~/lib/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/lib/methods/toggleFollowThread.ts b/app/lib/methods/toggleFollowThread.ts new file mode 100644 index 00000000000..f5873c4944e --- /dev/null +++ b/app/lib/methods/toggleFollowThread.ts @@ -0,0 +1,14 @@ +import I18n from '~/i18n'; +import EventEmitter from './helpers/events'; +import log from './helpers/log'; +import { toggleFollowMessage } from '../services/restApi'; +import { LISTENER } from '~/containers/Toast'; + +export const toggleFollowThread = async (threadMessageId: string, isFollowingThread: boolean): Promise => { + try { + await toggleFollowMessage(threadMessageId, !isFollowingThread); + EventEmitter.emit(LISTENER, { message: isFollowingThread ? I18n.t('Unfollowed_thread') : I18n.t('Following_thread') }); + } catch (e) { + log(e); + } +}; diff --git a/app/lib/navigation/appNavigation.test.ts b/app/lib/navigation/appNavigation.test.ts new file mode 100644 index 00000000000..3e8380d652a --- /dev/null +++ b/app/lib/navigation/appNavigation.test.ts @@ -0,0 +1,28 @@ +import Navigation from './appNavigation'; + +const mockDispatch = jest.fn(); + +describe('popToRoom', () => { + beforeEach(() => { + jest.clearAllMocks(); + Navigation.navigationRef.current = { dispatch: mockDispatch } as any; + }); + + it('merges params when popping back to a retained RoomView', () => { + Navigation.popToRoom(false); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'POP_TO', + payload: { name: 'RoomView', params: undefined, merge: true } + }); + }); + + it('pops to DrawerNavigator on master detail', () => { + Navigation.popToRoom(true); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'POP_TO', + payload: { name: 'DrawerNavigator', params: undefined, merge: undefined } + }); + }); +}); diff --git a/app/lib/navigation/appNavigation.ts b/app/lib/navigation/appNavigation.ts index 58ae4966448..b74fdc00687 100644 --- a/app/lib/navigation/appNavigation.ts +++ b/app/lib/navigation/appNavigation.ts @@ -24,8 +24,8 @@ function replace(name: string, params: any) { } // Pops to the first occurrence of the given route name, usually RoomView -function popTo(name: string) { - navigationRef.current?.dispatch(StackActions.popTo(name)); +function popTo(name: string, params?: any, options?: { merge?: boolean }) { + navigationRef.current?.dispatch(StackActions.popTo(name, params, options)); } // Removes RoomView from the stack and leaves only RoomsListView open @@ -47,7 +47,8 @@ function popToRoom(isMasterDetail: boolean) { if (isMasterDetail) { popTo('DrawerNavigator'); } else { - popTo('RoomView'); + // merge keeps the retained RoomView's existing params instead of replacing them with undefined + popTo('RoomView', undefined, { merge: true }); } } diff --git a/app/reducers/index.js b/app/reducers/index.js index 4258685f94f..ae7824146c8 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -1,6 +1,7 @@ import { combineReducers } from 'redux'; import inquiry from '../ee/omnichannel/reducers/inquiry'; +import routingConfig from '../ee/omnichannel/reducers/routingConfig'; import settings from './settings'; import login from './login'; import meteor from './connect'; @@ -45,6 +46,7 @@ export default combineReducers({ inviteLinks, createDiscussion, inquiry, + routingConfig, enterpriseModules, encryption, permissions, diff --git a/app/sagas/index.js b/app/sagas/index.js index b60cc211837..d5b65d01919 100644 --- a/app/sagas/index.js +++ b/app/sagas/index.js @@ -1,6 +1,7 @@ import { all } from 'redux-saga/effects'; import inquiry from '../ee/omnichannel/sagas/inquiry'; +import routingConfig from '../ee/omnichannel/sagas/routingConfig'; import login from './login'; import rooms from './rooms'; import room from './room'; @@ -30,6 +31,7 @@ const root = function* root() { inviteLinks(), createDiscussion(), inquiry(), + routingConfig(), encryption(), videoConf(), troubleshootingNotification() diff --git a/app/stacks/MasterDetailStack/types.ts b/app/stacks/MasterDetailStack/types.ts index e8c8e9bd30f..cdd11f23bba 100644 --- a/app/stacks/MasterDetailStack/types.ts +++ b/app/stacks/MasterDetailStack/types.ts @@ -42,11 +42,13 @@ export type ModalStackParamList = { }; }; RoomInfoView: { - room: ISubscription; - member: any; + room?: ISubscription; + member?: any; rid: string; t: SubscriptionType; showCloseModal?: boolean; + fromRid?: string; + itsMe?: boolean; }; SelectListView: { data: any; diff --git a/app/stacks/types.ts b/app/stacks/types.ts index 8c1b6608281..734985edd49 100644 --- a/app/stacks/types.ts +++ b/app/stacks/types.ts @@ -14,7 +14,8 @@ import { type TDataSelect, type TMessageAction, type TSubscriptionModel, - type TThreadModel + type TThreadModel, + type IVisitor } from '../definitions'; import { type ModalStackParamList } from './MasterDetailStack/types'; import { type TNavigation } from './stackType'; @@ -38,7 +39,8 @@ export type ChatsStackParamList = { name?: string; fname?: string; prid?: string; - room?: TSubscriptionModel | { rid: string; t: string; name?: string; fname?: string; prid?: string }; + visitor?: IVisitor; + joinCodeRequired?: boolean; jumpToMessageId?: string; jumpToThreadId?: string; roomUserId?: string | null; diff --git a/app/views/MessagesView/index.tsx b/app/views/MessagesView/index.tsx index 9b8b8b07cb7..4444710265b 100644 --- a/app/views/MessagesView/index.tsx +++ b/app/views/MessagesView/index.tsx @@ -9,6 +9,7 @@ import { type EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-cont import { type MasterDetailInsideStackParamList } from '~/stacks/MasterDetailStack/types'; import Message from '~/containers/message'; import { MessageRoomProvider } from '~/containers/message/stores/MessageRoomStore'; +import { A11yGateProvider } from '~/containers/message/stores/A11yGate'; import ActivityIndicator from '~/containers/ActivityIndicator'; import I18n from '~/i18n'; import getFileUrlAndTypeFromMessage from './getFileUrlAndTypeFromMessage'; @@ -24,7 +25,6 @@ import { type IApplicationState, type IRoomInfoParam, type TMessageModel, - type ISubscription, SubscriptionType, type IAttachment, type IMessage, @@ -44,7 +44,6 @@ interface IMessagesViewProps { username: string; token: string; }; - baseUrl: string; navigation: CompositeNavigationProp< NativeStackNavigationProp, NativeStackNavigationProp @@ -72,7 +71,6 @@ interface IParams { name?: string; fname?: string; prid?: string; - room?: ISubscription; jumpToMessageId?: string; jumpToThreadId?: string; roomUserId?: string; @@ -82,7 +80,6 @@ class MessagesView extends Component { private rid: string; private t: SubscriptionType; private content: any; - private room?: ISubscription; constructor(props: IMessagesViewProps) { super(props); @@ -141,8 +138,7 @@ class MessagesView extends Component { let params: IParams = { rid: this.rid, jumpToMessageId: item._id, - t: this.t, - room: this.room + t: this.t }; if (item.tmid) { Navigation.popToRoom(isMasterDetail); @@ -291,6 +287,8 @@ class MessagesView extends Component { navigation.navigate('AttachmentView', { attachment }); }; + messageHandlers = { navToRoomInfo: this.navToRoomInfo, showAttachment: this.showAttachment }; + onLongPress = (message: IMessage) => { this.setState({ message }, this.showActionSheet); }; @@ -334,7 +332,7 @@ class MessagesView extends Component { render() { const { messages, loading } = this.state; - const { theme, user, baseUrl, insets } = this.props; + const { theme, insets } = this.props; if (!loading && messages.length === 0) { return this.renderEmpty(); @@ -342,31 +340,25 @@ class MessagesView extends Component { return ( - - item._id} - onEndReached={this.load} - contentContainerStyle={{ paddingBottom: insets.bottom }} - ListFooterComponent={loading ? : null} - /> - + + + item._id} + onEndReached={this.load} + contentContainerStyle={{ paddingBottom: insets.bottom }} + ListFooterComponent={loading ? : null} + /> + + ); } } const mapStateToProps = (state: IApplicationState) => ({ - baseUrl: state.server.server, user: getUserSelector(state) }); diff --git a/app/views/ModalBlockView.tsx b/app/views/ModalBlockView.tsx index d9eb9802d67..290977e8dff 100644 --- a/app/views/ModalBlockView.tsx +++ b/app/views/ModalBlockView.tsx @@ -17,8 +17,8 @@ import { triggerBlockAction, triggerCancel, triggerSubmitView } from '../lib/met import { type IApplicationState, type TAnyMessageModel } from '../definitions'; import KeyboardView from '../containers/KeyboardView'; import { MessageRoomProvider } from '../containers/message/stores/MessageRoomStore'; +import { A11yGateProvider } from '../containers/message/stores/A11yGate'; import { MessageProvider } from '../containers/message/stores/MessageStore'; -import { getUserSelector } from '../selectors/login'; const styles = StyleSheet.create({ content: { @@ -54,8 +54,6 @@ interface IModalBlockViewProps { route: RouteProp; theme: TSupportedThemes; language: string; - user: ReturnType; - baseUrl: string; } // eslint-disable-next-line no-sequences @@ -97,7 +95,7 @@ const LoadingIndicator = ({ loading }: { loading: boolean }) => { // A UIKit modal is not a message, but the shared media components it can render // (ImageContainer -> Button -> Touchable) assume a per-message context. Provide an // empty one: no long-press target, no id-scoped cache. Images still load via the -// room provider's user/baseUrl. Routed through `unknown` (the repo's convention for +// user/baseUrl the media hooks now read from redux. Routed through `unknown` (the repo's convention for // fake TAnyMessageModel fixtures) so the cast reads as intentional, not a real message. const EMPTY_MESSAGE = {} as unknown as TAnyMessageModel; @@ -260,7 +258,7 @@ class ModalBlockView extends Component - - - - - + + + + + + + @@ -295,9 +295,7 @@ class ModalBlockView extends Component ({ - language: state.login.user.language as string, - baseUrl: state.server.server, - user: getUserSelector(state) + language: state.login.user.language as string }); export default connect(mapStateToProps)(ModalBlockView); diff --git a/app/views/RoomView/Banner.tsx b/app/views/RoomView/Banner.tsx deleted file mode 100644 index 66af6bb1f88..00000000000 --- a/app/views/RoomView/Banner.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { memo, useState } from 'react'; -import { Text } from 'react-native'; -import { BorderlessButton, GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler'; -import Modal from 'react-native-modal'; - -import Markdown, { MarkdownPreview } from '~/containers/markdown'; -import { CustomIcon } from '~/containers/CustomIcon'; -import { themes } from '~/lib/constants/colors'; -import styles from './styles'; -import { useTheme } from '~/theme'; - -interface IBannerProps { - text?: string; - title?: string; - bannerClosed?: boolean; - closeBanner: () => void; -} - -const Banner = memo( - ({ text, title, bannerClosed, closeBanner }: IBannerProps) => { - const [showModal, openModal] = useState(false); - const { theme } = useTheme(); - - const toggleModal = () => openModal(prevState => !prevState); - - if (text && !bannerClosed) { - return ( - <> - - - - - - - - - {title} - - - - - - - ); - } - - return null; - }, - (prevProps, nextProps) => prevProps.text === nextProps.text && prevProps.bannerClosed === nextProps.bannerClosed -); - -export default Banner; diff --git a/app/views/RoomView/JoinCode.tsx b/app/views/RoomView/JoinCode.tsx deleted file mode 100644 index ff9dba9b79e..00000000000 --- a/app/views/RoomView/JoinCode.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { forwardRef, memo, useImperativeHandle, useState } from 'react'; -import { InteractionManager, StyleSheet, Text, View } from 'react-native'; -import Modal from 'react-native-modal'; -import { connect } from 'react-redux'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; - -import I18n from '~/i18n'; -import Button from '~/containers/Button'; -import { FormTextInput } from '~/containers/TextInput'; -import sharedStyles from '../Styles'; -import { themes } from '~/lib/constants/colors'; -import { joinRoom } from '~/lib/services/restApi'; -import { type TSupportedThemes } from '~/theme'; -import { withMasterDetail } from '~/lib/hooks/useMasterDetail'; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center' - }, - content: { - padding: 16, - width: '100%', - borderRadius: 4 - }, - title: { - fontSize: 16, - paddingBottom: 8, - ...sharedStyles.textBold, - ...sharedStyles.textAlignCenter - }, - button: { - minWidth: 96, - marginBottom: 0 - }, - buttonContainer: { - flexDirection: 'row', - justifyContent: 'space-between' - }, - tablet: { - height: undefined - } -}); - -export interface IJoinCodeProps { - rid: string; - t: string; - onJoin: Function; - isMasterDetail: boolean; - theme: TSupportedThemes; -} - -export interface IJoinCode { - show: () => void; -} - -const JoinCode = memo( - forwardRef(({ rid, t, onJoin, isMasterDetail, theme }, ref) => { - const [visible, setVisible] = useState(false); - const [error, setError] = useState(false); - const [code, setCode] = useState(''); - - const show = () => setVisible(true); - - const hide = () => setVisible(false); - - const handleJoinRoom = async () => { - try { - await joinRoom(rid, code, t as any); - onJoin(); - hide(); - } catch (e) { - setError(true); - } - }; - - useImperativeHandle(ref, () => ({ show })); - - return ( - - - - {I18n.t('Insert_Join_Code')} - { - if (e) { - InteractionManager.runAfterInteractions(() => { - e.focus(); - }); - } - }} - returnKeyType='send' - autoCapitalize='none' - onChangeText={setCode} - onSubmitEditing={handleJoinRoom} - placeholder={I18n.t('Join_Code')} - secureTextEntry - error={error ? { error: 'error-code-invalid', reason: I18n.t('Code_or_password_invalid') } : undefined} - testID='join-code-input' - /> - -